Added the ability to bookmark the extension, along with some visual adjustments
This commit is contained in:
@@ -316,15 +316,15 @@ scrolling is reserved for table containers below
|
|||||||
1120px. The scoped table stylesheet does not change product-table column widths
|
1120px. The scoped table stylesheet does not change product-table column widths
|
||||||
or the existing light/dark colors.
|
or the existing light/dark colors.
|
||||||
|
|
||||||
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
|
Product HTTP requests appear in Debug Requests (Ctrl+Shift+D) as soon as they start.
|
||||||
Each entry identifies the opportunity and page offset, stays `running` while
|
Each entry identifies the opportunity and page offset, stays `running` while
|
||||||
waiting for headers or the response body, and updates with HTTP status, response,
|
waiting for headers or the response body, and updates with HTTP status, response,
|
||||||
duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
||||||
mask authorization and sensitive response headers before messaging the source
|
mask authorization and sensitive response headers before messaging the source
|
||||||
tab/frame; this change adds no timeout, retry or authentication behavior.
|
tab/frame; this change adds no timeout, retry or authentication behavior.
|
||||||
|
|
||||||
Debug Requests opens as a full-page view inside the extension using Ctrl+D, without
|
Debug Requests opens as a full-page view inside the extension using Ctrl+Shift+D, without
|
||||||
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D
|
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+Shift+D
|
||||||
returns to the underlying view. Header/category navigation remains above the
|
returns to the underlying view. Header/category navigation remains above the
|
||||||
scrollable timeline; expanded details use two columns on wide screens and one on
|
scrollable timeline; expanded details use two columns on wide screens and one on
|
||||||
narrow screens. Theme-specific colors distinguish success, failure, running,
|
narrow screens. Theme-specific colors distinguish success, failure, running,
|
||||||
@@ -393,3 +393,5 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
|
|||||||
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
|
||||||
|
|
||||||
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.
|
||||||
|
|
||||||
|
With the Opportunities Extension modal open, press Ctrl+Shift+F to choose a browser bookmarks folder and save the extension shortcut.
|
||||||
|
|||||||
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. */
|
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||||
|
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||||
|
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||||
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
|
|||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
runtimeApi
|
runtimeApi
|
||||||
}) {
|
}) {
|
||||||
|
const bookmarkPending = new Map();
|
||||||
|
async function getBookmarkFolders() {
|
||||||
|
const folders = [];
|
||||||
|
function visit(nodes, ancestors = []) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.url || node.unmodifiable) continue;
|
||||||
|
const path = node.parentId ? [...ancestors, node.title] : ancestors;
|
||||||
|
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
|
||||||
|
visit(node.children || [], path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(await runtimeApi.bookmarks.getTree());
|
||||||
|
return { ok: true, folders };
|
||||||
|
}
|
||||||
|
function addShortcutBookmark(parentId) {
|
||||||
|
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
|
||||||
|
const pending = (async () => {
|
||||||
|
const { folders } = await getBookmarkFolders();
|
||||||
|
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
|
||||||
|
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
|
||||||
|
const existing = await runtimeApi.bookmarks.search({ url });
|
||||||
|
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
|
||||||
|
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
|
||||||
|
return { ok: true, existing: false };
|
||||||
|
})().finally(() => { bookmarkPending.delete(parentId); });
|
||||||
|
bookmarkPending.set(parentId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (message.type === messageTypes.addShortcutBookmark) {
|
||||||
|
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getBookmarkFolders) {
|
||||||
|
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getShortcutBookmarkStatus) {
|
||||||
|
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
|
||||||
|
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
||||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
|||||||
390
dist/chromium/content.js
vendored
390
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
12
dist/chromium/manifest.json
vendored
12
dist/chromium/manifest.json
vendored
@@ -4,6 +4,7 @@
|
|||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"bookmarks",
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage",
|
"storage",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
@@ -15,6 +16,17 @@
|
|||||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"shortcut-intent.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
|
|||||||
11
dist/chromium/shortcut-intent.js
vendored
Normal file
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. */
|
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||||
|
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||||
|
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||||
@@ -3137,10 +3140,52 @@ function registerBackgroundMessages({
|
|||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
runtimeApi
|
runtimeApi
|
||||||
}) {
|
}) {
|
||||||
|
const bookmarkPending = new Map();
|
||||||
|
async function getBookmarkFolders() {
|
||||||
|
const folders = [];
|
||||||
|
function visit(nodes, ancestors = []) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.url || node.unmodifiable) continue;
|
||||||
|
const path = node.parentId ? [...ancestors, node.title] : ancestors;
|
||||||
|
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
|
||||||
|
visit(node.children || [], path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(await runtimeApi.bookmarks.getTree());
|
||||||
|
return { ok: true, folders };
|
||||||
|
}
|
||||||
|
function addShortcutBookmark(parentId) {
|
||||||
|
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
|
||||||
|
const pending = (async () => {
|
||||||
|
const { folders } = await getBookmarkFolders();
|
||||||
|
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
|
||||||
|
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
|
||||||
|
const existing = await runtimeApi.bookmarks.search({ url });
|
||||||
|
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
|
||||||
|
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
|
||||||
|
return { ok: true, existing: false };
|
||||||
|
})().finally(() => { bookmarkPending.delete(parentId); });
|
||||||
|
bookmarkPending.set(parentId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (message.type === messageTypes.addShortcutBookmark) {
|
||||||
|
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getBookmarkFolders) {
|
||||||
|
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getShortcutBookmarkStatus) {
|
||||||
|
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
|
||||||
|
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
||||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
|||||||
390
dist/firefox/content.js
vendored
390
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
12
dist/firefox/manifest.json
vendored
12
dist/firefox/manifest.json
vendored
@@ -4,6 +4,7 @@
|
|||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"bookmarks",
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage",
|
"storage",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
@@ -15,6 +16,17 @@
|
|||||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
|
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"shortcut-intent.js"
|
||||||
|
],
|
||||||
|
"all_frames": false,
|
||||||
|
"run_at": "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
|
|||||||
11
dist/firefox/shortcut-intent.js
vendored
Normal file
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);
|
||||||
|
})();
|
||||||
@@ -9,6 +9,7 @@ const distDir = path.join(rootDir, "dist");
|
|||||||
const contentScript = composition.composeScript("content");
|
const contentScript = composition.composeScript("content");
|
||||||
const backgroundScript = composition.composeScript("background");
|
const backgroundScript = composition.composeScript("background");
|
||||||
const spaPageScript = composition.composeScript("spa-page");
|
const spaPageScript = composition.composeScript("spa-page");
|
||||||
|
const shortcutScript = await readFile(path.join(rootDir, "src/content/shortcut-intent.js"), "utf8");
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
const baseManifest = {
|
const baseManifest = {
|
||||||
@@ -17,6 +18,7 @@ const baseManifest = {
|
|||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.9",
|
version: "1.0.9",
|
||||||
permissions: [
|
permissions: [
|
||||||
|
"bookmarks",
|
||||||
"cookies",
|
"cookies",
|
||||||
"storage",
|
"storage",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
@@ -28,6 +30,12 @@ const baseManifest = {
|
|||||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
|
{
|
||||||
|
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||||
|
js: ["shortcut-intent.js"],
|
||||||
|
all_frames: false,
|
||||||
|
run_at: "document_start"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matches: [
|
matches: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
|
||||||
@@ -77,7 +85,7 @@ for (const target of targets) {
|
|||||||
JSON.parse(manifestJson);
|
JSON.parse(manifestJson);
|
||||||
|
|
||||||
if (checkOnly) {
|
if (checkOnly) {
|
||||||
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript };
|
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript, "shortcut-intent.js": shortcutScript };
|
||||||
for (const [file, contents] of Object.entries(expected)) {
|
for (const [file, contents] of Object.entries(expected)) {
|
||||||
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
|
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
|
||||||
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
|
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
|
||||||
@@ -90,6 +98,7 @@ for (const target of targets) {
|
|||||||
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||||
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
||||||
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
|
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
|
||||||
|
await output.writeOutput(path.join(outputDir, "shortcut-intent.js"), shortcutScript);
|
||||||
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ module.exports = {
|
|||||||
"src/content/icons.js",
|
"src/content/icons.js",
|
||||||
"src/content/svg.js",
|
"src/content/svg.js",
|
||||||
"src/content/styles.js",
|
"src/content/styles.js",
|
||||||
|
"src/content/dialog-drag.js",
|
||||||
"src/auth/content-session.js",
|
"src/auth/content-session.js",
|
||||||
"src/auth/current-user.js",
|
"src/auth/current-user.js",
|
||||||
"src/auth/token-relay.js",
|
"src/auth/token-relay.js",
|
||||||
|
|||||||
@@ -44,10 +44,52 @@ function registerBackgroundMessages({
|
|||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
runtimeApi
|
runtimeApi
|
||||||
}) {
|
}) {
|
||||||
|
const bookmarkPending = new Map();
|
||||||
|
async function getBookmarkFolders() {
|
||||||
|
const folders = [];
|
||||||
|
function visit(nodes, ancestors = []) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.url || node.unmodifiable) continue;
|
||||||
|
const path = node.parentId ? [...ancestors, node.title] : ancestors;
|
||||||
|
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
|
||||||
|
visit(node.children || [], path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(await runtimeApi.bookmarks.getTree());
|
||||||
|
return { ok: true, folders };
|
||||||
|
}
|
||||||
|
function addShortcutBookmark(parentId) {
|
||||||
|
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
|
||||||
|
const pending = (async () => {
|
||||||
|
const { folders } = await getBookmarkFolders();
|
||||||
|
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
|
||||||
|
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
|
||||||
|
const existing = await runtimeApi.bookmarks.search({ url });
|
||||||
|
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
|
||||||
|
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
|
||||||
|
return { ok: true, existing: false };
|
||||||
|
})().finally(() => { bookmarkPending.delete(parentId); });
|
||||||
|
bookmarkPending.set(parentId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (message.type === messageTypes.addShortcutBookmark) {
|
||||||
|
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getBookmarkFolders) {
|
||||||
|
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.getShortcutBookmarkStatus) {
|
||||||
|
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
|
||||||
|
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
||||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const contentModal = createContentModal({
|
const contentModal = createContentModal({
|
||||||
|
getShortcutBookmarkStatus: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getShortcutBookmarkStatus }),
|
||||||
|
openBookmarkPicker: () => opportunitiesLinks.chooseBookmarkFolder(
|
||||||
|
() => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getBookmarkFolders }),
|
||||||
|
async parentId => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.addShortcutBookmark, parentId });
|
||||||
|
if (result?.ok) contentModal.hideBookmarkHint();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
),
|
||||||
createManageTimeButton: () => manageTime.createButton(),
|
createManageTimeButton: () => manageTime.createButton(),
|
||||||
prepareSrSession: () => srSessionPreparation.ensure(),
|
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||||
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||||
@@ -212,7 +221,9 @@ function createContentApplication(overrides = {}) {
|
|||||||
return result.items;
|
return result.items;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const dialogDrag = createDialogDrag({ environment });
|
||||||
const contentStyles = createContentStyles({
|
const contentStyles = createContentStyles({
|
||||||
|
enableDialogDrag: () => dialogDrag.install(),
|
||||||
STYLE_ID: contracts.STYLE_ID,
|
STYLE_ID: contracts.STYLE_ID,
|
||||||
redwoodStyles,
|
redwoodStyles,
|
||||||
environment
|
environment
|
||||||
@@ -250,6 +261,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunitiesController = createOpportunitiesController({
|
const opportunitiesController = createOpportunitiesController({
|
||||||
|
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
|
||||||
ensureCurrentUser: () => currentUser.ensure(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||||
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||||
@@ -273,7 +285,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
||||||
});
|
});
|
||||||
const fusionOpportunities = createFusionOpportunities({
|
const fusionOpportunities = createFusionOpportunities({
|
||||||
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
|
|
||||||
resetDebugRequests: () => debugState.reset(),
|
resetDebugRequests: () => debugState.reset(),
|
||||||
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
||||||
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
||||||
@@ -507,6 +518,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const opportunitiesLinks = createOpportunitiesLinks({
|
const opportunitiesLinks = createOpportunitiesLinks({
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
searchableSelect,
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const preferencesStorage = createPreferencesStorage({
|
const preferencesStorage = createPreferencesStorage({
|
||||||
|
|||||||
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
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { Node, document, window } = environment;
|
const { Node, document, window } = environment;
|
||||||
|
let shortcutOpened = false;
|
||||||
|
function openShortcutIfRequested() {
|
||||||
|
if (shortcutOpened || !isAllowedPage()) return;
|
||||||
|
if (window.top && window.top !== window) return;
|
||||||
|
let pending = false;
|
||||||
|
try {
|
||||||
|
const timestamp = Number(window.sessionStorage.getItem("opportunitiesExtension.shortcutIntent"));
|
||||||
|
pending = timestamp > 0 && Date.now() >= timestamp && Date.now() - timestamp < 10 * 60 * 1000;
|
||||||
|
window.sessionStorage.removeItem("opportunitiesExtension.shortcutIntent");
|
||||||
|
} catch { /* Storage may be disabled. */ }
|
||||||
|
if (!pending && window.location.hash !== "#opportunities-extension") return;
|
||||||
|
shortcutOpened = true;
|
||||||
|
openOpportunitiesModal();
|
||||||
|
}
|
||||||
function createOpportunitiesTile() {
|
function createOpportunitiesTile() {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "flat-grid-cell";
|
wrapper.className = "flat-grid-cell";
|
||||||
@@ -88,6 +102,9 @@ function createContentFusionPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function insertTile() {
|
function insertTile() {
|
||||||
|
// Opening a bookmarked shortcut must not depend on Sales being expanded
|
||||||
|
// or its tile insertion point being present in Fusion's lazy navigation.
|
||||||
|
openShortcutIfRequested();
|
||||||
if (document.getElementById(TILE_ID)) {
|
if (document.getElementById(TILE_ID)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ function startContentApplication({ environment, page, styles, products }) {
|
|||||||
if (!page.isAllowedPage()) return;
|
if (!page.isAllowedPage()) return;
|
||||||
styles.ensureExtensionStyles();
|
styles.ensureExtensionStyles();
|
||||||
products.ensureProductsBackgroundMessageHandler();
|
products.ensureProductsBackgroundMessageHandler();
|
||||||
|
environment.window?.addEventListener?.("hashchange", () => page.insertTile());
|
||||||
if (page.insertTile()) return;
|
if (page.insertTile()) return;
|
||||||
const observer = new environment.MutationObserver(() => page.insertTile());
|
const observer = new environment.MutationObserver(() => page.insertTile());
|
||||||
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
|
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
function createContentModal({
|
function createContentModal({
|
||||||
|
openBookmarkPicker,
|
||||||
|
getShortcutBookmarkStatus,
|
||||||
ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID,
|
||||||
AUTH_STATUS,
|
AUTH_STATUS,
|
||||||
CUSTOMER_FILTER_BUTTON_ID,
|
CUSTOMER_FILTER_BUTTON_ID,
|
||||||
@@ -72,6 +74,27 @@ function createContentModal({
|
|||||||
}) {
|
}) {
|
||||||
const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
|
const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
|
||||||
let authCompletion = null;
|
let authCompletion = null;
|
||||||
|
let bookmarkHintTimer = null;
|
||||||
|
let bookmarkHintVersion = 0;
|
||||||
|
function hideBookmarkHint() {
|
||||||
|
bookmarkHintVersion++;
|
||||||
|
if (bookmarkHintTimer !== null) clearTimeout(bookmarkHintTimer);
|
||||||
|
bookmarkHintTimer = null;
|
||||||
|
const hint = document.getElementById("opportunities-extension-bookmark-hint");
|
||||||
|
if (hint) hint.hidden = true;
|
||||||
|
}
|
||||||
|
async function refreshBookmarkHint() {
|
||||||
|
hideBookmarkHint();
|
||||||
|
const version = bookmarkHintVersion;
|
||||||
|
try {
|
||||||
|
const result = await getShortcutBookmarkStatus?.();
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
const hint = document.getElementById("opportunities-extension-bookmark-hint");
|
||||||
|
if (version !== bookmarkHintVersion || !modal || modal.hidden || !hint || !result?.ok || result.bookmarked) return;
|
||||||
|
hint.hidden = false;
|
||||||
|
bookmarkHintTimer = setTimeout(hideBookmarkHint, 20000);
|
||||||
|
} catch { /* A failed bookmarks check must not interrupt opportunities. */ }
|
||||||
|
}
|
||||||
function resetAuthCompletion() {
|
function resetAuthCompletion() {
|
||||||
if (!authCompletion) return;
|
if (!authCompletion) return;
|
||||||
clearTimeout(authCompletion.waitTimer);
|
clearTimeout(authCompletion.waitTimer);
|
||||||
@@ -89,6 +112,7 @@ function createContentModal({
|
|||||||
|
|
||||||
if (existingModal) {
|
if (existingModal) {
|
||||||
existingModal.hidden = false;
|
existingModal.hidden = false;
|
||||||
|
refreshBookmarkHint();
|
||||||
prepareSrSession?.();
|
prepareSrSession?.();
|
||||||
existingModal.querySelector("select").focus();
|
existingModal.querySelector("select").focus();
|
||||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
@@ -148,7 +172,13 @@ function createContentModal({
|
|||||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||||
productsCacheTimestamp.setAttribute("role", "status");
|
productsCacheTimestamp.setAttribute("role", "status");
|
||||||
productsCacheTimestamp.hidden = true;
|
productsCacheTimestamp.hidden = true;
|
||||||
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
const bookmarkHint = document.createElement("span");
|
||||||
|
bookmarkHint.id = "opportunities-extension-bookmark-hint";
|
||||||
|
bookmarkHint.className = "opportunities-extension-products-cache-timestamp";
|
||||||
|
bookmarkHint.setAttribute("role", "status");
|
||||||
|
bookmarkHint.textContent = "Save to favorites: Ctrl+Shift+F";
|
||||||
|
bookmarkHint.hidden = true;
|
||||||
|
subtitleRow.append(productsCacheTimestamp, bookmarkHint, authBadge, productsProgressBadge);
|
||||||
|
|
||||||
const titleBlock = document.createElement("div");
|
const titleBlock = document.createElement("div");
|
||||||
titleBlock.className = "opportunities-extension-title-row";
|
titleBlock.className = "opportunities-extension-title-row";
|
||||||
@@ -420,6 +450,7 @@ function createContentModal({
|
|||||||
shell.append(header, body);
|
shell.append(header, body);
|
||||||
overlay.append(shell);
|
overlay.append(shell);
|
||||||
document.body.append(overlay);
|
document.body.append(overlay);
|
||||||
|
refreshBookmarkHint();
|
||||||
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
document.documentElement.classList.add("opportunities-extension-scroll-lock");
|
||||||
ensureEscapeKeyHandler();
|
ensureEscapeKeyHandler();
|
||||||
renderStageFilterButtons();
|
renderStageFilterButtons();
|
||||||
@@ -472,6 +503,7 @@ function createContentModal({
|
|||||||
|
|
||||||
function closeOpportunitiesModal() {
|
function closeOpportunitiesModal() {
|
||||||
if (closeOrganizationChart()) return;
|
if (closeOrganizationChart()) return;
|
||||||
|
hideBookmarkHint();
|
||||||
resetAuthCompletion();
|
resetAuthCompletion();
|
||||||
closeCrmSrPopover();
|
closeCrmSrPopover();
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
@@ -502,10 +534,14 @@ function createContentModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.ctrlKey && event.key.toLowerCase() === "d") {
|
const shortcutKey = event.key.toLowerCase();
|
||||||
|
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey &&
|
||||||
|
(shortcutKey === "d" || shortcutKey === "f")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleDebugPanel();
|
if (event.repeat) return;
|
||||||
|
if (shortcutKey === "d") toggleDebugPanel();
|
||||||
|
else openBookmarkPicker();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,5 +616,5 @@ function createContentModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
|
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler, hideBookmarkHint, refreshBookmarkHint };
|
||||||
}
|
}
|
||||||
|
|||||||
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({
|
function createContentStyles({
|
||||||
STYLE_ID,
|
STYLE_ID,
|
||||||
|
enableDialogDrag,
|
||||||
redwoodStyles,
|
redwoodStyles,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
function ensureExtensionStyles() {
|
function ensureExtensionStyles() {
|
||||||
|
enableDialogDrag?.();
|
||||||
if (document.getElementById(STYLE_ID)) {
|
if (document.getElementById(STYLE_ID)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
/* Modal titles follow the customer Service Requests heading. Keep section and
|
/* Modal titles follow the customer Service Requests heading. Keep section and
|
||||||
calendar headings independent from dialog typography. */
|
calendar headings independent from dialog typography. */
|
||||||
|
.opportunities-extension-modal :is(.opportunities-extension-action-form-dialog,
|
||||||
|
.opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog,
|
||||||
|
.oe-org-confirm-panel) :is(header, h2, h3) {
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal [data-dialog-dragging="true"] :is(header, h2, h3) { cursor: grabbing; }
|
||||||
|
.opportunities-extension-modal #opportunities-extension-bookmark-hint {
|
||||||
|
background: #9a4600;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #b95b00;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] #opportunities-extension-bookmark-hint {
|
||||||
|
background: #ffca70;
|
||||||
|
color: #3b2400;
|
||||||
|
border-color: #ffd99a;
|
||||||
|
}
|
||||||
.opportunities-extension-modal :is(
|
.opportunities-extension-modal :is(
|
||||||
.opportunities-extension-header,
|
.opportunities-extension-header,
|
||||||
.opportunities-extension-account-plans-header,
|
.opportunities-extension-account-plans-header,
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
|
|||||||
function toggle(kind, value) {
|
function toggle(kind, value) {
|
||||||
const selected = selections[kind];
|
const selected = selections[kind];
|
||||||
if (!selected || !allowsCrmActivities()) return;
|
if (!selected || !allowsCrmActivities()) return;
|
||||||
if (selected.has(value)) selected.delete(value);
|
if (selected.has("matched") && selected.has("missing")) {
|
||||||
|
selected.clear();
|
||||||
|
selected.add(value);
|
||||||
|
} else if (selected.has(value)) selected.delete(value);
|
||||||
else {
|
else {
|
||||||
if (value === "under4") selected.clear();
|
if (value === "under4") selected.clear();
|
||||||
else selected.delete("under4");
|
else selected.delete("under4");
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
viewbar.append(tablist);
|
viewbar.append(tablist);
|
||||||
if (productTypeSelection) {
|
if (productTypeSelection) {
|
||||||
const filters = node("div", "opportunities-product-type-filters");
|
const filters = node("div", "opportunities-product-type-filters");
|
||||||
|
const filterButtons = new Map();
|
||||||
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
||||||
for (const type of ["Booking", "Workload"]) {
|
for (const type of ["Booking", "Workload"]) {
|
||||||
const button = node("button", "opportunities-product-type-filter", type);
|
const button = node("button", "opportunities-product-type-filter", type);
|
||||||
@@ -217,10 +218,16 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
button.setAttribute("data-type", type);
|
button.setAttribute("data-type", type);
|
||||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
if (["Booking", "Workload"].every(value => productTypeSelection.has(value))) {
|
||||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
productTypeSelection.clear();
|
||||||
|
productTypeSelection.add(type);
|
||||||
|
} else if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||||
|
for (const [value, filterButton] of filterButtons) {
|
||||||
|
filterButton.setAttribute("aria-pressed", String(productTypeSelection.has(value)));
|
||||||
|
}
|
||||||
onProductTypeChange();
|
onProductTypeChange();
|
||||||
});
|
});
|
||||||
|
filterButtons.set(type, button);
|
||||||
filters.append(button);
|
filters.append(button);
|
||||||
}
|
}
|
||||||
if (createPrintButton) filters.append(createPrintButton());
|
if (createPrintButton) filters.append(createPrintButton());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function createOpportunitiesController({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress,
|
renderProductsProgress,
|
||||||
requestOpportunitiesPage,
|
requestOpportunitiesPage,
|
||||||
|
loadServiceRequests = async () => {},
|
||||||
resetCrmServiceRequests,
|
resetCrmServiceRequests,
|
||||||
resetWorkloadActions,
|
resetWorkloadActions,
|
||||||
resetWorkloads,
|
resetWorkloads,
|
||||||
@@ -95,6 +96,9 @@ function createOpportunitiesController({
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
message: ""
|
message: ""
|
||||||
});
|
});
|
||||||
|
// Start SR only after all opportunity pages have been displayed.
|
||||||
|
// Its incremental updates run independently of the opportunity request.
|
||||||
|
try { Promise.resolve(loadServiceRequests(token, requestVersion)).catch(() => {}); } catch { /* Keep the displayed opportunities available. */ }
|
||||||
requestOpptyProductsInBackground(allItems, token);
|
requestOpptyProductsInBackground(allItems, token);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -28,12 +28,28 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
let items = cached?.items;
|
let items = cached?.items;
|
||||||
if (!items) {
|
if (!items) {
|
||||||
reportProgress("running", 0);
|
reportProgress("running", 0);
|
||||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
items = await requestCrmServiceRequest(token, version, isActive, (count, total, partialItems) => {
|
||||||
if (isActive()) reportProgress("running", count, total);
|
if (!isActive()) return;
|
||||||
|
reportProgress("running", count, total);
|
||||||
|
if (Array.isArray(partialItems)) {
|
||||||
|
updateItems(partialItems);
|
||||||
|
onStateChange();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||||
}
|
}
|
||||||
if (!items || !isActive()) return false;
|
if (!items || !isActive()) return false;
|
||||||
|
updateItems(items);
|
||||||
|
status = "ready";
|
||||||
|
if (!cached) reportProgress("complete", items.length, items.length);
|
||||||
|
onStateChange();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (isActive()) { status = "error"; reportProgress("error"); onStateChange(); }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateItems(items) {
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||||
@@ -44,15 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
}
|
}
|
||||||
byOpportunity = grouped;
|
byOpportunity = grouped;
|
||||||
loadedItems = items;
|
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) {
|
function getSummary(optyNumber) {
|
||||||
if (!allowsCrmActivities() || !optyNumber) return null;
|
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))
|
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
|
||||||
).map(Number);
|
).map(Number);
|
||||||
return {
|
return {
|
||||||
state: status === "ready" ? items.length ? "matched" : "missing" : status,
|
state: status === "loading" && items.length ? "matched" : status === "ready" ? items.length ? "matched" : "missing" : status,
|
||||||
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
|
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
|
||||||
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
|
hasUnderFourHours: (status === "ready" || status === "loading") && hours.some(value => value < 4)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function createBadge(optyNumber) {
|
function createBadge(optyNumber) {
|
||||||
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
const badgeStatus = status === "loading" && byOpportunity.get(String(optyNumber))?.length ? "ready" : status;
|
||||||
|
if (!allowsCrmActivities() || !optyNumber || badgeStatus === "idle" || badgeStatus === "loading") return null;
|
||||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||||
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
const badge = environment.document.createElement(items.length && badgeStatus === "ready" ? "button" : "span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge";
|
badge.className = "opportunities-extension-crm-sr-badge";
|
||||||
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
badge.setAttribute("data-state", badgeStatus === "error" ? "error" : items.length ? "matched" : "missing");
|
||||||
const summary = getSummary(optyNumber);
|
const summary = getSummary(optyNumber);
|
||||||
const reportedHours = summary.reportedHours !== null
|
const reportedHours = summary.reportedHours !== null
|
||||||
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||||
: "N/A";
|
: "N/A";
|
||||||
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
|
setBadgeContent(badge, "sr", badgeStatus === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
|
||||||
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
|
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
|
||||||
const descriptions = items.map(item => [
|
const descriptions = items.map(item => [
|
||||||
`Title: ${display(item.Title)}`,
|
`Title: ${display(item.Title)}`,
|
||||||
@@ -87,13 +95,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
|
const description = badgeStatus === "error" ? "Unable to check CRM service requests. See Debug Requests."
|
||||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
||||||
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||||
badge.setAttribute("aria-label", description);
|
badge.setAttribute("aria-label", description);
|
||||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
if (items.length && badgeStatus === "ready") bindPopover(badge, items, descriptions);
|
||||||
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
if (!items.length && badgeStatus === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ function createOpportunitiesFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleStageFilter(stage) {
|
function toggleStageFilter(stage) {
|
||||||
if (opportunitiesFilterState.selectedStages.has(stage)) {
|
if (STAGE_OPTIONS.every(value => opportunitiesFilterState.selectedStages.has(value))) {
|
||||||
|
opportunitiesFilterState.selectedStages.clear();
|
||||||
|
opportunitiesFilterState.selectedStages.add(stage);
|
||||||
|
} else if (opportunitiesFilterState.selectedStages.has(stage)) {
|
||||||
opportunitiesFilterState.selectedStages.delete(stage);
|
opportunitiesFilterState.selectedStages.delete(stage);
|
||||||
} else {
|
} else {
|
||||||
opportunitiesFilterState.selectedStages.add(stage);
|
opportunitiesFilterState.selectedStages.add(stage);
|
||||||
@@ -99,7 +102,10 @@ function createOpportunitiesFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleStatusFilter(status) {
|
function toggleStatusFilter(status) {
|
||||||
if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
if (STATUS_OPTIONS.every(value => opportunitiesFilterState.selectedStatuses.has(value))) {
|
||||||
|
opportunitiesFilterState.selectedStatuses.clear();
|
||||||
|
opportunitiesFilterState.selectedStatuses.add(status);
|
||||||
|
} else if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
||||||
opportunitiesFilterState.selectedStatuses.delete(status);
|
opportunitiesFilterState.selectedStatuses.delete(status);
|
||||||
} else {
|
} else {
|
||||||
opportunitiesFilterState.selectedStatuses.add(status);
|
opportunitiesFilterState.selectedStatuses.add(status);
|
||||||
@@ -246,7 +252,10 @@ function createOpportunitiesFilters({
|
|||||||
checkbox.type = "checkbox";
|
checkbox.type = "checkbox";
|
||||||
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
||||||
checkbox.addEventListener("change", () => {
|
checkbox.addEventListener("change", () => {
|
||||||
if (checkbox.checked) {
|
if (getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key))) {
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys.clear();
|
||||||
|
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||||
|
} else if (checkbox.checked) {
|
||||||
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||||
} else {
|
} else {
|
||||||
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
||||||
@@ -316,7 +325,10 @@ function createOpportunitiesFilters({
|
|||||||
checkbox.type = "checkbox";
|
checkbox.type = "checkbox";
|
||||||
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
||||||
checkbox.addEventListener("change", () => {
|
checkbox.addEventListener("change", () => {
|
||||||
if (checkbox.checked) {
|
if (getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key))) {
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys.clear();
|
||||||
|
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||||
|
} else if (checkbox.checked) {
|
||||||
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||||
} else {
|
} else {
|
||||||
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
||||||
|
|||||||
@@ -1,8 +1,140 @@
|
|||||||
function createOpportunitiesLinks({
|
function createOpportunitiesLinks({
|
||||||
MODAL_ID,
|
MODAL_ID,
|
||||||
|
searchableSelect,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document, navigator, window } = environment;
|
const { document, navigator, window } = environment;
|
||||||
|
function chooseBookmarkFolder(loadFolders, saveBookmark) {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
if (!modal || modal.querySelector(".oe-bookmark-picker")) return;
|
||||||
|
const previous = document.activeElement;
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "opportunities-extension-action-form-modal oe-bookmark-picker";
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
panel.className = "opportunities-extension-action-form-dialog";
|
||||||
|
panel.setAttribute("role", "dialog");
|
||||||
|
panel.setAttribute("aria-modal", "true");
|
||||||
|
panel.setAttribute("aria-labelledby", "oe-bookmark-title");
|
||||||
|
const header = document.createElement("header");
|
||||||
|
header.className = "opportunities-extension-account-plans-header";
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = "oe-bookmark-title";
|
||||||
|
title.textContent = "Add to favorites";
|
||||||
|
const dismiss = document.createElement("button");
|
||||||
|
dismiss.type = "button";
|
||||||
|
dismiss.className = "opportunities-extension-icon-button";
|
||||||
|
dismiss.setAttribute("aria-label", "Close favorites form");
|
||||||
|
const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
closeIcon.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
closeIcon.setAttribute("aria-hidden", "true");
|
||||||
|
const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
closePath.setAttribute("d", "M6 6l12 12M18 6 6 18");
|
||||||
|
closePath.setAttribute("stroke", "currentColor");
|
||||||
|
closePath.setAttribute("stroke-width", "1.75");
|
||||||
|
closeIcon.append(closePath);
|
||||||
|
dismiss.append(closeIcon);
|
||||||
|
header.append(title, dismiss);
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "opportunities-extension-action-form";
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "opportunities-extension-action-form-field opportunities-extension-action-form-wide";
|
||||||
|
label.textContent = "Folder (required)";
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = "oe-bookmark-folder";
|
||||||
|
select.required = true;
|
||||||
|
select.setAttribute("aria-describedby", "oe-bookmark-status");
|
||||||
|
select.disabled = true;
|
||||||
|
label.append(select);
|
||||||
|
const status = document.createElement("p");
|
||||||
|
status.id = "oe-bookmark-status";
|
||||||
|
status.className = "opportunities-extension-action-form-status opportunities-extension-action-form-wide";
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
status.textContent = "Loading bookmarks folders…";
|
||||||
|
const actions = document.createElement("footer");
|
||||||
|
actions.className = "opportunities-extension-action-form-footer";
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.textContent = "Cancel";
|
||||||
|
const save = document.createElement("button");
|
||||||
|
save.type = "button";
|
||||||
|
save.className = "opportunities-extension-action-form-save";
|
||||||
|
save.textContent = "Save";
|
||||||
|
save.disabled = true;
|
||||||
|
const autocomplete = searchableSelect(select, label, () => { save.disabled = !select.value; }, { floating: true });
|
||||||
|
autocomplete.input.setAttribute("aria-describedby", "oe-bookmark-status");
|
||||||
|
let saving = false;
|
||||||
|
const siblings = Array.from(modal.children).map(node => [node, node.inert]);
|
||||||
|
siblings.forEach(([node]) => { node.inert = true; });
|
||||||
|
function close() {
|
||||||
|
if (saving) return;
|
||||||
|
autocomplete.destroy();
|
||||||
|
overlay.remove();
|
||||||
|
siblings.forEach(([node, inert]) => { node.inert = inert; });
|
||||||
|
previous?.focus();
|
||||||
|
}
|
||||||
|
cancel.addEventListener("click", close);
|
||||||
|
dismiss.addEventListener("click", close);
|
||||||
|
save.addEventListener("click", async () => {
|
||||||
|
if (!select.value || saving) return;
|
||||||
|
saving = true;
|
||||||
|
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = true;
|
||||||
|
autocomplete.sync();
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
status.textContent = "Saving favorite…";
|
||||||
|
try {
|
||||||
|
const result = await saveBookmark(select.value);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to add favorite.");
|
||||||
|
saving = false;
|
||||||
|
close();
|
||||||
|
showCopyConfirmation(result.existing ? "This shortcut is already in the selected folder." : "Opportunities Extension added to favorites.");
|
||||||
|
} catch (error) {
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message;
|
||||||
|
saving = false;
|
||||||
|
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = false;
|
||||||
|
autocomplete.sync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [dismiss, autocomplete.input, cancel, save].filter(control => !control.disabled);
|
||||||
|
event.preventDefault();
|
||||||
|
const index = controls.indexOf(document.activeElement);
|
||||||
|
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.append(cancel, save);
|
||||||
|
body.append(label, status, actions);
|
||||||
|
panel.append(header, body);
|
||||||
|
overlay.append(panel);
|
||||||
|
modal.append(overlay);
|
||||||
|
cancel.focus();
|
||||||
|
Promise.resolve().then(loadFolders).then(result => {
|
||||||
|
if (!overlay.isConnected) return;
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load bookmarks folders.");
|
||||||
|
const placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
placeholder.textContent = "Select a folder";
|
||||||
|
select.append(placeholder);
|
||||||
|
for (const folder of result.folders) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = folder.id;
|
||||||
|
option.textContent = folder.label;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
select.disabled = !result.folders.length;
|
||||||
|
status.textContent = result.folders.length ? "Choose where to save Opportunities Extension." : "No writable bookmarks folders available.";
|
||||||
|
select.addEventListener("change", () => { save.disabled = !select.value; });
|
||||||
|
autocomplete.sync();
|
||||||
|
if (!select.disabled) autocomplete.input.focus();
|
||||||
|
}).catch(error => {
|
||||||
|
if (overlay.isConnected) {
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
function createDetailLink(label, href) {
|
function createDetailLink(label, href) {
|
||||||
if (label === null || label === undefined || label === "") {
|
if (label === null || label === undefined || label === "") {
|
||||||
const placeholder = document.createElement("span");
|
const placeholder = document.createElement("span");
|
||||||
@@ -114,5 +246,5 @@ function createOpportunitiesLinks({
|
|||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
return { chooseBookmarkFolder, createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,10 +111,14 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
checkbox.type = "checkbox";
|
checkbox.type = "checkbox";
|
||||||
checkbox.checked = selectedOrders.has(value);
|
checkbox.checked = selectedOrders.has(value);
|
||||||
checkbox.addEventListener("change", () => {
|
checkbox.addEventListener("change", () => {
|
||||||
if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
|
if (orders.every(order => selectedOrders.has(order))) {
|
||||||
|
selectedOrders.clear();
|
||||||
|
selectedOrders.add(value);
|
||||||
|
} else if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
|
||||||
allOrders.checked = selectedOrders.size === orders.length;
|
allOrders.checked = selectedOrders.size === orders.length;
|
||||||
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
|
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
|
||||||
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
|
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
|
||||||
|
updateOrders();
|
||||||
renderRows();
|
renderRows();
|
||||||
});
|
});
|
||||||
label.append(checkbox, element("span", null, value || "Not provided"));
|
label.append(checkbox, element("span", null, value || "Not provided"));
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
throw new Error("CRM service request pagination did not advance.");
|
throw new Error("CRM service request pagination did not advance.");
|
||||||
}
|
}
|
||||||
items.push(...data.items);
|
items.push(...data.items);
|
||||||
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null, items.slice());
|
||||||
if (!data.hasMore) return items;
|
if (!data.hasMore) return items;
|
||||||
offset = current + received;
|
offset = current + received;
|
||||||
attempt = 1;
|
attempt = 1;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ function createFusionOpportunities({
|
|||||||
getOpportunityTypeValues,
|
getOpportunityTypeValues,
|
||||||
readResponseBodyForDebug,
|
readResponseBodyForDebug,
|
||||||
resetDebugRequests = () => {},
|
resetDebugRequests = () => {},
|
||||||
loadServiceRequests = async () => {},
|
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
@@ -40,9 +39,6 @@ function createFusionOpportunities({
|
|||||||
// One history per query, preserving all pages of the same result set.
|
// One history per query, preserving all pages of the same result set.
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
resetDebugRequests();
|
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;
|
if (!isCurrentPeriodRequest(requestVersion)) return null;
|
||||||
}
|
}
|
||||||
const requestEntry = logRequestStart({
|
const requestEntry = logRequestStart({
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
addShortcutBookmark: "opportunitiesExtension.addShortcutBookmark",
|
||||||
|
getBookmarkFolders: "opportunitiesExtension.getBookmarkFolders",
|
||||||
|
getShortcutBookmarkStatus: "opportunitiesExtension.getShortcutBookmarkStatus",
|
||||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ test("activity badge filters precede Status, are accessible and combine without
|
|||||||
h.sr.set("B", { state: "missing" });
|
h.sr.set("B", { state: "missing" });
|
||||||
h.actions.set("B", { state: "matched" });
|
h.actions.set("B", { state: "matched" });
|
||||||
assert.equal(h.matches("unknown"), true);
|
assert.equal(h.matches("unknown"), true);
|
||||||
h.click("sr", "missing");
|
h.click("sr", "matched");
|
||||||
|
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "true");
|
||||||
|
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
|
||||||
assert.equal(h.matches("A"), true);
|
assert.equal(h.matches("A"), true);
|
||||||
assert.equal(h.matches("B"), false);
|
assert.equal(h.matches("B"), false);
|
||||||
assert.equal(h.matches("unknown"), false);
|
assert.equal(h.matches("unknown"), false);
|
||||||
h.click("actions", "missing");
|
h.click("actions", "matched");
|
||||||
assert.equal(h.matches("A"), false, "SR and Action use AND");
|
assert.equal(h.matches("A"), false, "SR and Action use AND");
|
||||||
h.actions.set("A", { state: "matched" });
|
h.actions.set("A", { state: "matched" });
|
||||||
assert.equal(h.matches("A"), true);
|
assert.equal(h.matches("A"), true);
|
||||||
@@ -119,8 +121,8 @@ test("filter vectors retain their fixed slot without duplication when selection
|
|||||||
|
|
||||||
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
|
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
h.click("sr", "matched");
|
h.click("sr", "missing");
|
||||||
h.click("actions", "matched");
|
h.click("actions", "missing");
|
||||||
for (const state of ["idle", "loading", "error", "matched"]) {
|
for (const state of ["idle", "loading", "error", "matched"]) {
|
||||||
h.sr.set("A", { state });
|
h.sr.set("A", { state });
|
||||||
h.actions.set("A", { state: "missing" });
|
h.actions.set("A", { state: "missing" });
|
||||||
@@ -215,7 +217,7 @@ test("activity filters compose with existing search, status, owner and customer
|
|||||||
h.app.opportunitiesFilters.resetOwnerFilter(items);
|
h.app.opportunitiesFilters.resetOwnerFilter(items);
|
||||||
h.sr.set("A", { state: "matched" });
|
h.sr.set("A", { state: "matched" });
|
||||||
h.sr.set("B", { state: "missing" });
|
h.sr.set("B", { state: "missing" });
|
||||||
h.click("sr", "missing");
|
h.click("sr", "matched");
|
||||||
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
|
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
|
||||||
assert.deepEqual(results(), ["A"]);
|
assert.deepEqual(results(), ["A"]);
|
||||||
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
||||||
|
|||||||
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 = {
|
const base = {
|
||||||
manifest_version: 3, name: "Opportunities Extension",
|
manifest_version: 3, name: "Opportunities Extension",
|
||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.9", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
|
version: "1.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/*"],
|
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
|
||||||
content_scripts: [{
|
content_scripts: [{
|
||||||
|
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||||
|
js: ["shortcut-intent.js"], all_frames: false, run_at: "document_start"
|
||||||
|
}, {
|
||||||
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||||
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
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,
|
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
|
||||||
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
|
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
|
||||||
|
|
||||||
function setup(items = [opportunity("A", "2026-11-15")]) {
|
function setup(items = [opportunity("A", "2026-11-15")], extra = {}) {
|
||||||
let document;
|
let document;
|
||||||
class Element {
|
class Element {
|
||||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
||||||
@@ -44,7 +44,7 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
|
|||||||
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
|
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
|
||||||
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
|
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
|
||||||
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
|
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
|
||||||
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} });
|
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {}, ...extra });
|
||||||
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
|
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
|
||||||
const walk = element => [element, ...element.children.flatMap(walk)];
|
const walk = element => [element, ...element.children.flatMap(walk)];
|
||||||
h.nodes = () => walk(host);
|
h.nodes = () => walk(host);
|
||||||
@@ -54,6 +54,23 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("Booking and Workload update both badges when isolating and adding selections", () => {
|
||||||
|
const selected = new Set(["Booking", "Workload"]);
|
||||||
|
let renders = 0;
|
||||||
|
const h = setup([], { productTypeSelection: selected, onProductTypeChange: () => renders++ });
|
||||||
|
h.mount();
|
||||||
|
const booking = h.nodes().find(node => node.attributes["data-type"] === "Booking");
|
||||||
|
const workload = h.nodes().find(node => node.attributes["data-type"] === "Workload");
|
||||||
|
for (const [button, expected] of [[booking, [true,false]], [workload, [true,true]], [workload, [false,true]], [booking, [true,true]]]) {
|
||||||
|
button.click();
|
||||||
|
assert.equal(booking.attributes["aria-pressed"], String(expected[0]));
|
||||||
|
assert.equal(workload.attributes["aria-pressed"], String(expected[1]));
|
||||||
|
assert.equal(selected.has("Booking"), expected[0]);
|
||||||
|
assert.equal(selected.has("Workload"), expected[1]);
|
||||||
|
}
|
||||||
|
assert.equal(renders, 4);
|
||||||
|
});
|
||||||
|
|
||||||
test("calendar controls, accessibility labels and missing dates are English", () => {
|
test("calendar controls, accessibility labels and missing dates are English", () => {
|
||||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
|
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
|
||||||
const { options } = h.activate();
|
const { options } = h.activate();
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -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 {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');
|
const factory=vm.runInNewContext(fs.readFileSync('src/features/opportunities/controller.js','utf8')+'\ncreateOpportunitiesController');
|
||||||
for(const mode of ['success','failure','stale'])test(`SR precedes opportunities: ${mode}`,async()=>{
|
for(const mode of ['success','failure','stale','pending'])test(`opportunities are displayed before background SR: ${mode}`,async()=>{
|
||||||
const order=[];let active=true;let page=0;
|
const order=[];const state={periodRequestVersion:1};let complete;
|
||||||
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})};}}});
|
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')});
|
||||||
await api.requestOpportunitiesPage('token','CQ',{startCloseDate:'2026-09-01',endCloseDate:'2026-12-01'},0,[],1,1,'saved-search');
|
const request=api.requestOpportunities('token','CQ',{},1);
|
||||||
assert.deepEqual(order,mode==='stale'?['reset','sr']:['reset','sr','opportunities','opportunities']);
|
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