Replaced labels with icons in the SR and Action badges

This commit is contained in:
2026-09-27 19:18:24 -03:00
parent de4da0eec6
commit 1a3decbeb6
37 changed files with 1856 additions and 279 deletions

View File

@@ -37,11 +37,22 @@ If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles. other opportunity features continue. Reload the page to refresh identity/roles.
The unused identity token is discarded and redacted from debug responses. The unused identity token is discarded and redacted from debug responses.
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
preserves the originating window and Firefox cookie store, and retries within
the same five-attempt limit. Debug entries retain the HTTP status and recovery
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`, `CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest` using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
query endpoint, independently of the opportunity query version. Calls appear in query endpoint, independently of the opportunity query version. Calls appear in
the Service Requests debug category with masked credentials. the Service Requests debug category with masked credentials.
After a complete successful SR query (HTTP 200 with valid responses on all pages),
its result is reused for subsequent Reference/product cycles in the current page
session. Refresh all explicitly clears it. Failed pages are retried up to five
total attempts each; completed pages are not requested again. Resetting the query
stops further retries from the previous run.
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
one or more matches; its tooltip lists each SR number, title, reported hours, owner, one or more matches; its tooltip lists each SR number, title, reported hours, owner,
@@ -60,7 +71,8 @@ Products loaded from cache restore matching activity entries without network cal
Missing, expired or mismatched entries are not treated as confirmed empty results; Missing, expired or mismatched entries are not treated as confirmed empty results;
they wait for a real product refresh before a new activity query is allowed. they wait for a real product refresh before a new activity query is allowed.
Empty and entirely failed product cycles do not query activities. In mixed cycles, Empty and entirely failed product cycles do not query activities. In mixed cycles,
only fresh products permit activity API calls on cache misses; SR refreshes once. only fresh products permit activity API calls on cache misses; an already-loaded
SR result is reused and associated with the new product update.
Activity lookups do not advance the existing Last update timestamp. Activity lookups do not advance the existing Last update timestamp.
Closing and reopening the modal preserves results and does not Closing and reopening the modal preserves results and does not
reload products or trigger these lookups. Already-running product requests may reload products or trigger these lookups. Already-running product requests may
@@ -68,6 +80,12 @@ finish in the background and update their related activities.
## Workload Actions ## Workload Actions
The missing Act badge includes a separate plus control that opens an Action form
with Team, Start date, End Date, Status, Complexity and Notes. This is currently
UI-only: Save is disabled and the form never submits a request. Cancel or
Escape dismisses the form. Creation options and API integration are pending;
the form is unavailable to SALES_REP users, like the other Action features.
SR and Action filter badges appear before Status for users allowed to view these SR and Action filter badges appear before Status for users allowed to view these
activities. They filter the loaded data without issuing requests and combine with activities. They filter the loaded data without issuing requests and combine with
the existing opportunity filters. The SR below 4h option replaces the other SR the existing opportunity filters. The SR below 4h option replaces the other SR
@@ -78,7 +96,11 @@ with both positive and negative options selected, all activity states remain
visible. These selections are kept in memory, not persisted. visible. These selections are kept in memory, not persisted.
Workload Actions are queried in the background after the product cycle completes, Workload Actions are queried in the background after the product cycle completes,
using only fresh API results. Each distinct `RevnId` uses `requestWlActions` with and only after SR has completed successfully (or was restored/reused). If SR is
unavailable, Actions are not requested and remain unknown rather than absent.
Each failed workload lookup gets up to five total attempts, stopping on success
or when its session is superseded. Cached products still do not trigger network
lookups. Each distinct `RevnId` uses `requestWlActions` with
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
aggregates all products: green means an action's `owner` matches the general aggregates all products: green means an action's `owner` matches the general
tokenrelay response's `principal`; red means no matching owner was found. `Act ?` tokenrelay response's `principal`; red means no matching owner was found. `Act ?`

View File

@@ -250,14 +250,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE" && error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200; && error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) { if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.attempt = String(attempt); debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) { if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true"; debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`; debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error; error.message = debugEntry.error;
} }
} }
@@ -285,7 +293,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt); const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.attempt = String(attempt); debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON"; debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }
@@ -874,7 +882,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry); appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) { if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`); const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
} }
if (responsePayload.error) { if (responsePayload.error) {

File diff suppressed because one or more lines are too long

View File

@@ -250,14 +250,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE" && error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200; && error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) { if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.attempt = String(attempt); debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) { if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true"; debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`; debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error; error.message = debugEntry.error;
} }
} }
@@ -285,7 +293,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt); const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.attempt = String(attempt); debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON"; debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }
@@ -874,7 +882,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry); appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) { if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`); const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
} }
if (responsePayload.error) { if (responsePayload.error) {

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,7 @@ module.exports = {
"src/features/opportunities/dashboard-state.js", "src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js", "src/features/debug/state.js",
"src/features/opportunities/columns.js", "src/features/opportunities/columns.js",
"src/features/opportunities/badge-content.js",
"src/content/fusion-page.js", "src/content/fusion-page.js",
"src/content/modal.js", "src/content/modal.js",
"src/content/theme.js", "src/content/theme.js",
@@ -39,6 +40,7 @@ module.exports = {
"src/features/opportunities/activity-filters.js", "src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js", "src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/workload-actions.js", "src/features/opportunities/workload-actions.js",
"src/features/opportunities/workload-action-form.js",
"src/features/opportunities/fiscal-periods.js", "src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js", "src/features/opportunities/table.js",
"src/features/opportunities/dashboard.js", "src/features/opportunities/dashboard.js",

View File

@@ -103,14 +103,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE" && error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200; && error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) { if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.attempt = String(attempt); debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) { if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true"; debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`; debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error; error.message = debugEntry.error;
} }
} }
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt); const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.attempt = String(attempt); debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON"; debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }

View File

@@ -55,6 +55,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const contentModal = createContentModal({ const contentModal = createContentModal({
closeActionForm: (...args) => workloadActionForm.close(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args), createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(), closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(), ensureCurrentUser: () => currentUser.ensure(),
@@ -220,7 +221,9 @@ function createContentApplication(overrides = {}) {
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
}); });
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache, activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args), reportProgress: (...args) => activityProgress.update("sr", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(), allowsCrmActivities: () => currentUser.allowsCrmActivities(),
@@ -356,6 +359,7 @@ function createContentApplication(overrides = {}) {
}); });
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment }); const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const activityFilters = createActivityFilters({ const activityFilters = createActivityFilters({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(), allowsCrmActivities: () => currentUser.allowsCrmActivities(),
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number), getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
getActionSummary: number => opportunityWorkloadActions.getSummary(number), getActionSummary: number => opportunityWorkloadActions.getSummary(number),
@@ -364,9 +368,9 @@ function createContentApplication(overrides = {}) {
}); });
const productActivities = createProductActivities({ const productActivities = createProductActivities({
productsContentState, opportunitiesQueryState, productsContentState, opportunitiesQueryState,
resetCrm: () => opportunityCrmServiceRequests.reset(),
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args), loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
blockActions: products => opportunityWorkloadActions.blockProducts(products),
render: () => { render: () => {
productsContentJobs.renderProductsProgress(); productsContentJobs.renderProductsProgress();
productsContentJobs.queueProductsTableRender(); productsContentJobs.queueProductsTableRender();
@@ -386,7 +390,19 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment environment
}); });
const workloadActionForm = createWorkloadActionForm({
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
MODAL_ID: contracts.MODAL_ID,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
environment
});
const opportunityWorkloadActions = createOpportunityWorkloadActions({ const opportunityWorkloadActions = createOpportunityWorkloadActions({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
createActionButton: number => workloadActionForm.createButton(number),
closeActionForm: (...args) => workloadActionForm.close(...args),
activityCache, activityCache,
reportProgress: (...args) => activityProgress.update("actions", ...args), reportProgress: (...args) => activityProgress.update("actions", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(), allowsCrmActivities: () => currentUser.allowsCrmActivities(),
@@ -398,6 +414,7 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment productsContentState, messageTypes, environment
}); });
const productsDetails = createProductsDetails({ const productsDetails = createProductsDetails({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns, OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args), createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
@@ -561,6 +578,7 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs }); startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
} }
return { return {
workloadActionForm,
activityFilters, activityFilters,
start, contracts, start, contracts,
opportunityWorkloadActions, opportunityWorkloadActions,

View File

@@ -28,6 +28,7 @@ function createContentModal({
closeAccountPlansModal, closeAccountPlansModal,
closeInstallBaseModal, closeInstallBaseModal,
closeServiceRequestsModal, closeServiceRequestsModal,
closeActionForm,
closeCustomerFilterPanel, closeCustomerFilterPanel,
closeOwnerFilterPanel, closeOwnerFilterPanel,
closePreferencesModal, closePreferencesModal,
@@ -470,6 +471,7 @@ function createContentModal({
closeAccountPlansModal(); closeAccountPlansModal();
closeInstallBaseModal(); closeInstallBaseModal();
closeServiceRequestsModal(); closeServiceRequestsModal();
closeActionForm(false);
modal.hidden = true; modal.hidden = true;
} }
@@ -498,6 +500,10 @@ function createContentModal({
} }
if (event.key === "Escape") { if (event.key === "Escape") {
if (closeActionForm()) {
event.preventDefault();
return;
}
if (closeServiceRequestsModal()) { if (closeServiceRequestsModal()) {
event.preventDefault(); event.preventDefault();
return; return;

View File

@@ -1,4 +1,4 @@
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, environment }) { function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) }; const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
let container = null; let container = null;
let form = null; let form = null;
@@ -12,24 +12,32 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
container = environment.document.createElement("div"); container = environment.document.createElement("div");
container.className = "opportunities-extension-badge-filters"; container.className = "opportunities-extension-badge-filters";
for (const [kind, title, options] of [ for (const [kind, title, options] of [
["sr", "Service Request", [["matched", "\u2705 SR"], ["under4", "\u2705 SR < 4h"], ["missing", "\u26d4 SR"]]], ["sr", "Service Request", ["matched", "under4", "missing"]],
["actions", "Actions", [["matched", "\u2705 Action"], ["missing", "\u26d4 Action"]]] ["actions", "Act", ["matched", "missing"]]
]) { ]) {
const section = environment.document.createElement("section"); const section = environment.document.createElement("section");
section.className = "opportunities-extension-activity-filter"; section.className = "opportunities-extension-activity-filter";
const label = environment.document.createElement("span"); const label = environment.document.createElement("span");
label.textContent = title; label.textContent = title;
const tooltip = kind === "sr"
? "Whether a Service Request has been created for the opportunities."
: "Whether the Action is registered in Workload Workbench.";
label.title = tooltip;
const controls = environment.document.createElement("div"); const controls = environment.document.createElement("div");
controls.className = "opportunities-extension-activity-filter-controls"; controls.className = "opportunities-extension-activity-filter-controls";
controls.setAttribute("role", "group"); controls.setAttribute("role", "group");
controls.setAttribute("aria-label", `Filter by ${title}`); controls.setAttribute("aria-label", `Filter by ${title}`);
for (const [value, text] of options) { for (const value of options) {
const button = environment.document.createElement("button"); const button = environment.document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "opportunities-extension-activity-filter-button"; button.className = "opportunities-extension-activity-filter-button";
button.setAttribute("data-value", value); button.setAttribute("data-value", value);
button.textContent = text; setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
if (value === "under4") button.title = "At least one SR with reported hours below 4; replaces other SR filters."; const description = value === "under4"
? "At least one SR with reported hours below 4; replaces other SR filters."
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
button.title = `${tooltip}\n${description}`;
button.setAttribute("aria-label", description);
button.addEventListener("click", () => toggle(kind, value)); button.addEventListener("click", () => toggle(kind, value));
buttons.push({ button, kind, value }); buttons.push({ button, kind, value });
controls.append(button); controls.append(button);

View File

@@ -0,0 +1,63 @@
/*
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
* https://github.com/lucide-icons/lucide (ISC License)
* Copyright (c) 2026 Lucide Icons and Contributors
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
function createOpportunityBadgeContent({ environment }) {
const shapes = {
products: [
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
["path", { d: "M12 22V12" }],
["polyline", { points: "3.29 7 12 12 20.71 7" }],
["path", { d: "m7.5 4.27 9 5.15" }]
],
ramp: [
["path", { d: "M5 21v-6" }],
["path", { d: "M12 21V9" }],
["path", { d: "M19 21V3" }]
],
sr: [
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
["path", { d: "M12 11h4" }],
["path", { d: "M12 16h4" }],
["path", { d: "M8 11h.01" }],
["path", { d: "M8 16h.01" }]
],
actions: [
["path", { d: "M13 5h8" }],
["path", { d: "M13 12h8" }],
["path", { d: "M13 19h8" }],
["path", { d: "m3 17 2 2 4-4" }],
["path", { d: "m3 7 2 2 4-4" }]
]
};
function setContent(badge, kind, value) {
badge.textContent = String(value);
const namespace = "http://www.w3.org/2000/svg";
const icon = environment.document.createElementNS(namespace, "svg");
for (const [name, attribute] of Object.entries({
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
width: "16", height: "16", fill: "none", stroke: "currentColor",
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
})) icon.setAttribute(name, attribute);
for (const [tag, attributes] of shapes[kind]) {
const shape = environment.document.createElementNS(namespace, tag);
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
icon.append(shape);
}
badge.append(icon);
}
return { setContent };
}

View File

@@ -1,12 +1,20 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, environment }) { function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
let status = "idle"; let status = "idle";
let byOpportunity = new Map(); let byOpportunity = new Map();
let pending = null; let pending = null;
let generation = 0; let generation = 0;
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); reportProgress("idle"); } let loadedItems = [];
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function load(token, version, cacheOptions = null) { function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return; if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation, cacheOptions); if (status === "ready") {
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
}
if (!pending) {
const request = run(token, version, generation, cacheOptions);
pending = request;
request.finally(() => { if (pending === request) pending = null; });
}
return pending; return pending;
} }
async function run(token, version, currentGeneration, cacheOptions) { async function run(token, version, currentGeneration, cacheOptions) {
@@ -14,8 +22,8 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
status = "loading"; status = "loading";
try { try {
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null; const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return; if (!isActive()) return false;
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return; } if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
let items = cached?.items; let items = cached?.items;
if (!items) { if (!items) {
reportProgress("running", 0); reportProgress("running", 0);
@@ -24,7 +32,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
}); });
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; if (!items || !isActive()) return false;
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;
@@ -34,10 +42,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
grouped.get(key).push(item); grouped.get(key).push(item);
} }
byOpportunity = grouped; byOpportunity = grouped;
loadedItems = items;
status = "ready"; status = "ready";
if (!cached) reportProgress("complete", items.length, items.length); if (!cached) reportProgress("complete", items.length, items.length);
return true;
} catch { } catch {
if (isActive()) { status = "error"; reportProgress("error"); } if (isActive()) { status = "error"; reportProgress("error"); }
return false;
} }
} }
function getSummary(optyNumber) { function getSummary(optyNumber) {
@@ -62,7 +73,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
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/D"; : "N/D";
badge.textContent = status === "error" ? "SR ?" : items.length ? `\u2705 SR (${reportedHours}${reportedHours === "N/D" ? "" : "h"})` : "\u26d4 SR"; setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value); const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
const descriptions = items.map(item => [ const descriptions = items.map(item => [
`Title: ${display(item.Title)}`, `Title: ${display(item.Title)}`,
@@ -76,8 +87,8 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
: !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.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions); if (items.length && status === "ready") bindPopover(badge, items, descriptions);
else badge.title = description;
badge.tabIndex = 0; badge.tabIndex = 0;
return badge; return badge;
} }

View File

@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
environment environment
}) { }) {
const { document, requestAnimationFrame, performance } = environment; const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items) { function renderStageDashboard(items, totalCount = items.length) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID); const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) { if (!dashboard) {
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
totalCard.setAttribute("data-stage", "TOTAL"); totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities"; totalLabel.textContent = "Total Opportunities";
totalValue.className = "opportunities-extension-stage-dashboard-value"; totalValue.className = "opportunities-extension-stage-dashboard-value";
totalValue.setAttribute("aria-label", "Total opportunities"); totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
animateStageDashboardValue( animateStageDashboardValue(
totalValue, totalValue,
"TOTAL", "TOTAL",
items.length, items.length,
(amount) => Math.round(amount).toLocaleString() (amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
); );
totalCard.append(totalLabel, totalValue); totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard); dashboard.append(totalCard);

View File

@@ -336,11 +336,14 @@ function createOpportunitiesFilters({
function getSortedOpportunities() { function getSortedOpportunities() {
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase(); const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => { const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c) return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
&& matchesSelectedStatus(item.StatusCode) && matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)) && (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)) && (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch) && matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item); && matchesActivities(item);
}); });
@@ -393,6 +396,7 @@ function createOpportunitiesFilters({
} }
function matchesSelectedStatus(statusCode) { function matchesSelectedStatus(statusCode) {
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
if (statusCode === "WON_PENDING") { if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON"); return opportunitiesFilterState.selectedStatuses.has("WON");
} }

View File

@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item); const key = getOwnerKey(item);
const label = getOwnerName(item); const label = String(getOwnerName(item)).trim() || "-";
if (key && label && !optionsByKey.has(key)) { if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) }); optionsByKey.set(key, { key, label: String(label) });
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
function getOwnerKey(item) { function getOwnerKey(item) {
const owner = item.Owner; const owner = item.Owner;
const partyId = owner && typeof owner === "object" ? owner.PartyId : ""; const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
const name = getOwnerName(item); const name = String(getOwnerName(item)).trim();
if (!name) return "owner:unassigned";
if (partyId !== null && partyId !== undefined && partyId !== "") { if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`; return `party:${partyId}`;

View File

@@ -1,5 +1,5 @@
function createProductActivities({ productsContentState, opportunitiesQueryState, resetCrm, function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, render }) { loadCrm, loadActions, blockActions, render }) {
let cycle = null; let cycle = null;
function begin(requestId, token) { function begin(requestId, token) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
@@ -11,26 +11,32 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
current.completed = true; current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error); const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return; if (!products.length) return;
resetCrm();
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
const id = String(item?.RevnId ?? "");
if (seen.has(id)) return false;
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
}
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0)); const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
pending.push(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })); const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
return Promise.allSettled(pending).then(() => { && opportunitiesQueryState.periodRequestVersion === current.version;
if (cycle !== current || productsContentState.currentProductsRequestId !== requestId return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
|| opportunitiesQueryState.periodRequestVersion !== current.version) return; if (!isActive()) return;
if (success === false) {
products.forEach(blockActions);
render();
return;
}
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
const id = String(item?.RevnId ?? "");
if (seen.has(id)) return false;
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
}
await Promise.allSettled(pending);
if (!isActive()) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt; if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render(); render();
}).catch(() => {}); }).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
} }
return { begin, complete }; return { begin, complete };
} }

View File

@@ -9,14 +9,7 @@
.opportunities-extension-auth-badge { transition: none; } .opportunities-extension-auth-badge { transition: none; }
} }
.opportunities-extension-crm-sr-badge { .opportunities-extension-crm-sr-badge {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #408254; border: 1px solid #408254;
border-radius: 8px;
font-size: 10px;
line-height: 18px;
font-weight: 600;
white-space: nowrap;
color: #185b30; color: #185b30;
background: #e1f1e5; background: #e1f1e5;
} }
@@ -91,14 +84,71 @@
} }
.opportunities-extension-form .opportunities-extension-stage-filter-button, .opportunities-extension-form .opportunities-extension-stage-filter-button,
.opportunities-extension-form .opportunities-extension-status-filter-button { .opportunities-extension-form .opportunities-extension-status-filter-button,
.opportunities-extension-form .opportunities-extension-activity-filter-button {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 28px;
min-height: 28px; min-height: 28px;
padding: 4px 7px; padding: 4px 7px;
border: 1px solid currentColor;
border-radius: 18px;
font-family: inherit;
font-size: 11px; font-size: 11px;
font-weight: 700;
letter-spacing: 0;
line-height: 18px; line-height: 18px;
white-space: nowrap; white-space: nowrap;
} }
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
display: inline-flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
flex: 0 0 auto;
gap: 4px;
height: 20px;
min-height: 20px;
margin: 0;
padding: 0 6px;
border-width: 1px;
border-style: solid;
border-radius: 8px;
font-family: inherit;
font-size: 10px;
font-style: normal;
font-weight: 600;
line-height: 16px;
letter-spacing: 0;
text-align: center;
vertical-align: middle;
white-space: nowrap;
}
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
display: block;
order: -1;
flex: 0 0 16px;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
color: inherit;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
@media (max-width: 1150px) { @media (max-width: 1150px) {
.opportunities-extension-form { .opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -139,17 +189,10 @@
column-gap: 8px; column-gap: 8px;
} }
.opportunities-extension-activity-filter-button { .opportunities-extension-activity-filter-button {
border: 1px solid currentColor; min-width: 32px;
border-radius: 18px; gap: 4px;
min-height: 28px;
padding: 4px 7px;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
color: #185b30; color: #185b30;
background: transparent; background: #fff;
cursor: pointer; cursor: pointer;
} }
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; } .opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
@@ -160,6 +203,7 @@
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } .opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
@@ -186,6 +230,105 @@
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); } .opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
} }
.opportunities-extension-add-action {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 16px;
width: 16px;
height: 16px;
padding: 0;
margin: 0 0 0 2px;
border: 0;
border-radius: 3px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-action-form-modal {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: 12px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-action-form-dialog {
box-sizing: border-box;
width: min(560px, 100%);
max-height: calc(100vh - 24px);
overflow-y: auto;
background: #fff;
color: #312d2a;
border: 1px solid #b9b4ae;
border-radius: 6px;
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
}
.opportunities-extension-action-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-context {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px 12px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
}
.opportunities-extension-action-form-context dt { font-weight: 600; }
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
.opportunities-extension-action-form-wide,
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
.opportunities-extension-action-form-field input,
.opportunities-extension-action-form-field textarea {
box-sizing: border-box;
min-width: 0;
width: 100%;
border: 1px solid #8f8983;
border-radius: 4px;
padding: 8px;
font: inherit;
line-height: 20px;
color: inherit;
background: #fff;
}
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-action-form-footer button {
border: 1px solid #8f8983;
border-radius: 4px;
padding: 7px 14px;
font: inherit;
font-size: 13px;
background: transparent;
color: inherit;
cursor: pointer;
}
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
color-scheme: dark;
background: #292725;
color: #eeeae6;
border-color: #756f68;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
@media (max-width: 420px) {
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
}
/* Keep layout rules on the outer table, never on expanded product tables. */ /* Keep layout rules on the outer table, never on expanded product tables. */
.opportunities-extension-table[data-opportunities-layout="inline-actions"] { .opportunities-extension-table[data-opportunities-layout="inline-actions"] {
width: 100%; width: 100%;
@@ -281,18 +424,11 @@
} }
.opportunities-extension-products-ramp { .opportunities-extension-products-ramp {
display: inline-block;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
padding: 0 6px;
border: 1px solid #bba5cf; border: 1px solid #bba5cf;
border-radius: 8px;
background: #f0e8f7; background: #f0e8f7;
color: #63358a; color: #63358a;
font-size: 10px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }

View File

@@ -101,7 +101,7 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody"); const tableBody = document.createElement("tbody");
const items = getSortedOpportunities(); const items = getSortedOpportunities();
renderStageDashboard(items); renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) { if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr"); const row = document.createElement("tr");

View File

@@ -0,0 +1,111 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, formatDate, createCloseIcon, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function close(restoreFocus = true) {
if (!active) return false;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(optyNumber) {
const button = element("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Action";
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
open(optyNumber, button);
});
return button;
}
function open(optyNumber, trigger) {
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = element("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
const dialog = element("div", "opportunities-extension-action-form-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div", "opportunities-extension-action-form-heading");
const title = element("h2", "", "Add Action");
title.id = "opportunities-extension-action-form-title";
heading.append(title, element("p", "", String(optyNumber)));
const opportunity = getOpportunity(optyNumber);
const details = element("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = element("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close";
dismiss.setAttribute("aria-label", "Close Action form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = element("form", "opportunities-extension-action-form");
// Form-only version: never submit or navigate until the API contract is provided.
form.addEventListener("submit", event => event.preventDefault());
const fields = [];
for (const [name, label, type] of [
["team", "Team", "text"], ["startDate", "Start date", "date"],
["endDate", "End Date", "date"], ["status", "Status", "text"],
["complexity", "Complexity", "text"], ["notes", "Notes", "textarea"]
]) {
const field = element("label", "opportunities-extension-action-form-field");
const input = element(type === "textarea" ? "textarea" : "input");
if (type !== "textarea") input.type = type;
else input.rows = 5;
input.name = name;
input.id = `opportunities-extension-action-form-${name}`;
field.htmlFor = input.id;
if (name === "notes" || name === "team") field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", label), input);
form.append(field);
fields.push(input);
}
const footer = element("footer", "opportunities-extension-action-form-footer");
const save = element("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
save.title = "Gravacao ainda nao disponivel";
const cancel = element("button", "", "Cancel");
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(save, cancel);
form.append(footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
active = { modal, trigger };
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key !== "Tab") return;
const controls = [dismiss, ...fields, cancel];
const index = controls.indexOf(document.activeElement);
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
fields[0].focus();
}
return { createButton, open, close };
}

View File

@@ -1,5 +1,5 @@
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage, function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, environment }) { appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
let session = newSession(); let session = newSession();
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; } function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
function updateProgress(current) { function updateProgress(current) {
@@ -10,6 +10,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length); reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
} }
function reset(enabled = true) { function reset(enabled = true) {
closeActionForm(false);
session.enabled = false; session.enabled = false;
for (const job of session.queue) job.record.resolveCompletion(); for (const job of session.queue) job.record.resolveCompletion();
session.queue.length = 0; session.queue.length = 0;
@@ -46,6 +47,12 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products); for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
queueProductsTableRender(); queueProductsTableRender();
} }
function blockProducts(products) {
if (!allowsCrmActivities()) return;
for (const id of idsFor(products)) {
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
}
}
function pump(current) { function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) { while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift(); const job = current.queue.shift();
@@ -66,12 +73,21 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; } if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
current.progress.add(record); current.progress.add(record);
updateProgress(current); updateProgress(current);
const authorizations = await requestConsumerTokenServiceOnce(); let result;
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return; for (let attempt = 1; attempt <= 5; attempt += 1) {
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions }); try {
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return; if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests); const authorizations = await requestConsumerTokenServiceOnce();
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions."); if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
break;
} catch (error) {
if (attempt === 5) throw error;
}
}
record.actions = result.actions; record.actions = result.actions;
record.hasNext = result.hasNext; record.hasNext = result.hasNext;
record.status = "ready"; record.status = "ready";
@@ -107,7 +123,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
const badge = environment.document.createElement("span"); const badge = environment.document.createElement("span");
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge"; badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
badge.setAttribute("data-state", state); badge.setAttribute("data-state", state);
badge.textContent = state === "matched" ? "\u2705 Act" : state === "missing" ? "\u26d4 Act" : state === "loading" ? "Act ..." : "Act ?"; setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
badge.title = actions.length ? actions.map(action => [ badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`, `Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`, `Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
@@ -120,7 +136,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked."; if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
badge.tabIndex = 0; badge.tabIndex = 0;
badge.setAttribute("aria-label", badge.title); badge.setAttribute("aria-label", badge.title);
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge; return badge;
} }
return { reset, reopen, loadProducts, createBadge, getSummary }; return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary };
} }

View File

@@ -1,5 +1,6 @@
function createProductsDetails({ function createProductsDetails({
OPPORTUNITIES_COLUMNS, OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert, createAiSummaryAlert,
formatOracleResponseDate, formatOracleResponseDate,
productsContentState, productsContentState,
@@ -17,7 +18,7 @@ function createProductsDetails({
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp"; badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D"))); badge.setAttribute("data-missing", String(values.includes("N/D")));
badge.textContent = `RM ${values.join(",")}`; setBadgeContent(badge, "ramp", values.join(","));
badge.title = `Ramp Months: ${values.join(", ")}`; badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title); badge.setAttribute("aria-label", badge.title);
return badge; return badge;
@@ -31,7 +32,7 @@ function createProductsDetails({
button.type = "button"; button.type = "button";
button.className = "opportunities-extension-products-count"; button.className = "opportunities-extension-products-count";
button.textContent = String(count); setBadgeContent(button, "products", count);
button.setAttribute("aria-expanded", String(isExpanded)); button.setAttribute("aria-expanded", String(isExpanded));
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;

View File

@@ -21,6 +21,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) { async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
const items = []; const items = [];
let offset = 0; let offset = 0;
let attempt = 1;
while (isActive()) { while (isActive()) {
const payload = createPayload(offset); const payload = createPayload(offset);
const headers = { const headers = {
@@ -28,31 +29,33 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient" Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
}; };
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url, const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset } }); headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload) method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
}); });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`CRM service request query failed with status ${response.status}.`); if (response.status !== 200) throw new Error(`CRM service request query failed with status ${response.status}.`);
const data = await response.json(); const data = await response.json();
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") { if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
throw new Error("CRM service request query returned an invalid response."); throw new Error("CRM service request query returned an invalid response.");
} }
if (!isActive()) return null; if (!isActive()) return null;
const received = data.count === undefined ? data.items.length : Number(data.count);
const current = data.offset === undefined ? offset : Number(data.offset);
if (data.hasMore && (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset)) {
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);
if (!data.hasMore) return items; if (!data.hasMore) return items;
const received = data.count === undefined ? data.items.length : Number(data.count);
const current = data.offset === undefined ? offset : Number(data.offset);
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset) {
throw new Error("CRM service request pagination did not advance.");
}
offset = current + received; offset = current + received;
attempt = 1;
} catch (error) { } catch (error) {
logRequestFailure(entry, error.message || "CRM service request query failed."); logRequestFailure(entry, error.message || "CRM service request query failed.");
throw error; if (attempt === 5) throw error;
attempt += 1;
} }
} }
return null; return null;

View File

@@ -25,7 +25,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry); appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) { if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`); const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
} }
if (responsePayload.error) { if (responsePayload.error) {

View File

@@ -14,7 +14,9 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
remove: (keys, done) => local.remove(keys).then(done) remove: (keys, done) => local.remove(keys).then(done)
} } } } } }
}; };
const app = loadApplication("content", { ...api, document: { createElement: () => ({ setAttribute() {}, addEventListener() {} }) } }); const document = { createElement: () => ({ append() {}, setAttribute() {}, addEventListener() {} }) };
document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { ...api, document });
app.authSessionState.principal = principal; app.authSessionState.principal = principal;
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
app.preferencesState.opptyProductsCacheHours = 24; app.preferencesState.opptyProductsCacheHours = 24;
@@ -59,7 +61,7 @@ for (const family of ["chrome", "firefox"]) {
const count = h.calls.length; const count = h.calls.length;
await h.load(currentStamp, true); await h.load(currentStamp, true);
assert.equal(h.calls.length, count); assert.equal(h.calls.length, count);
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (2h)"); assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "2h");
h.app.opportunitiesTable.renderOpportunitiesTable = () => {}; h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
h.app.activityFilters.toggle("sr", "under4"); h.app.activityFilters.toggle("sr", "under4");
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true); assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
@@ -82,15 +84,15 @@ for (const family of ["chrome", "firefox"]) {
const restored = setup(family, h.saved); const restored = setup(family, h.saved);
await restored.load(stamp, true); await restored.load(stamp, true);
assert.deepEqual(restored.calls, []); assert.deepEqual(restored.calls, []);
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (4h)"); assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "\u2705 Act"); assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "1");
await restored.load(stamp, false); await restored.load(stamp, false);
assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications"); assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications");
const other = setup(family, h.saved, "other@example.invalid"); const other = setup(family, h.saved, "other@example.invalid");
await other.load(stamp, true); await other.load(stamp, true);
assert.deepEqual(other.calls, []); assert.deepEqual(other.calls, []);
assert.equal(other.app.opportunityCrmServiceRequests.createBadge("OP1"), null); assert.equal(other.app.opportunityCrmServiceRequests.createBadge("OP1"), null);
assert.equal(other.app.opportunityWorkloadActions.createBadge("OP1").textContent, "Act ?"); assert.equal(other.app.opportunityWorkloadActions.createBadge("OP1").textContent, "?");
await restored.load(stamp + 1, false); await restored.load(stamp + 1, false);
assert.deepEqual(restored.calls, ["sr", "actions"]); assert.deepEqual(restored.calls, ["sr", "actions"]);
}); });
@@ -133,7 +135,7 @@ test("storage failure does not lose live activity results and failures are never
h.local.get = async () => { throw new Error("Synthetic storage read failure"); }; h.local.get = async () => { throw new Error("Synthetic storage read failure"); };
h.local.set = async () => { throw new Error("Synthetic storage write failure"); }; h.local.set = async () => { throw new Error("Synthetic storage write failure"); };
await h.load(Date.now() - 1000, false); await h.load(Date.now() - 1000, false);
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (4h)"); assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
const failed = setup("firefox"); const failed = setup("firefox");
failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); }; failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); };
failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false }); failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false });

View File

@@ -11,7 +11,7 @@ class Element {
addEventListener(key, handler) { this.listeners[key] = handler; } addEventListener(key, handler) { this.listeners[key] = handler; }
} }
function setup() { function setup() {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } }); const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
let renders = 0; let renders = 0;
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; }; app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
@@ -34,9 +34,28 @@ test("activity badge filters precede Status, are accessible and combine without
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request"); assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
assert.equal(h.controls.children[3], h.stage); assert.equal(h.controls.children[3], h.stage);
assert.equal(h.form.attributes["data-activity-filters"], "true"); assert.equal(h.form.attributes["data-activity-filters"], "true");
assert.equal(h.button("sr", "matched").textContent, "\u2705 SR"); assert.equal(h.button("sr", "matched").textContent, "");
assert.equal(h.button("actions", "missing").textContent, "\u26d4 Action"); assert.equal(h.button("actions", "missing").textContent, "");
assert.equal(h.button("actions", "matched").type, "button"); assert.equal(h.button("actions", "matched").type, "button");
assert.equal(h.controls.children[1].children[0].textContent, "Act");
for (const value of ["matched", "missing"]) {
assert.equal(h.button("actions", value).textContent, "");
assert.ok(h.button("actions", value).title.startsWith("Whether the Action is registered in Workload Workbench.\n"));
assert.ok(h.button("sr", value).title.startsWith("Whether a Service Request has been created for the opportunities.\n"));
for (const kind of ["sr", "actions"]) {
const button = h.button(kind, value);
assert.ok(button.title.endsWith(button.attributes["aria-label"]));
assert.equal(button.children.length, 1);
assert.equal(button.children[0].tag, "svg");
assert.equal(button.children[0].attributes["data-badge-icon"], kind);
assert.equal(button.children[0].attributes.stroke, "currentColor");
assert.equal(button.children[0].attributes["aria-hidden"], "true");
}
}
assert.equal(h.button("sr", "under4").textContent, "< 4h");
assert.equal(h.button("sr", "under4").children[0].attributes["data-badge-icon"], "sr");
assert.notEqual(h.button("actions", "matched").attributes["aria-label"], h.button("actions", "missing").attributes["aria-label"]);
assert.notEqual(h.button("sr", "matched").attributes["aria-label"], h.button("sr", "missing").attributes["aria-label"]);
h.sr.set("A", { state: "matched" }); h.sr.set("A", { state: "matched" });
h.actions.set("A", { state: "missing" }); h.actions.set("A", { state: "missing" });
h.sr.set("B", { state: "missing" }); h.sr.set("B", { state: "missing" });
@@ -74,6 +93,30 @@ test("SR below four hours is exclusive and regular SR options can replace it", (
assert.equal(h.matches("A"), false); assert.equal(h.matches("A"), false);
}); });
test("filter vectors retain their fixed slot without duplication when selection changes", () => {
const h = setup();
const options = [["sr", "matched"], ["sr", "under4"], ["sr", "missing"], ["actions", "matched"], ["actions", "missing"]];
const icons = options.map(([kind, value]) => h.button(kind, value).children[0]);
for (const [kind, value] of options) h.click(kind, value);
h.app.activityFilters.render();
options.forEach(([kind, value], index) => {
const button = h.button(kind, value);
assert.deepEqual(button.children, [icons[index]]);
assert.equal(icons[index].attributes.viewBox, "0 0 24 24");
assert.equal(icons[index].attributes.width, "16");
assert.equal(icons[index].attributes.height, "16");
assert.equal(icons[index].attributes["stroke-width"], "2.25");
assert.equal(icons[index].attributes.focusable, "false");
});
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-cell-actions \.opportunities-extension-badge-icon,\s*\.opportunities-extension-activity-filter-button \.opportunities-extension-badge-icon \{([^}]+)\}/);
assert.ok(shared, "filters and opportunity badges share the same vector positioning");
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "stroke: currentColor;"]) {
assert.ok(shared[1].includes(rule), rule);
}
assert.match(css, /activity-filter-button \{\s*min-width: 32px;\s*gap: 4px;/);
});
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", "matched");
@@ -98,7 +141,7 @@ test("missing filters exclude unknown, loading and error states; SALES_REP bypas
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) { for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => { test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } }); const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
const calls = []; const calls = [];
let finish; let finish;
app.platformContentMessages.sendRuntimeMessage = message => { app.platformContentMessages.sendRuntimeMessage = message => {
@@ -146,7 +189,7 @@ test("SR filter summary uses actual records: zero, decimals, exact four, missing
}); });
test("Action filter summary shares badge ownership and uncertainty rules", async () => { test("Action filter summary shares badge ownership and uncertainty rules", async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } }); const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
app.productsContentJobs.queueProductsTableRender = () => {}; app.productsContentJobs.queueProductsTableRender = () => {};
app.authSessionState.principal = "me@example.invalid"; app.authSessionState.principal = "me@example.invalid";
@@ -190,6 +233,16 @@ test("activity filters have theme contrast, visible keyboard focus and responsiv
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/); assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
}); });
test("SR and Act filters share sizing and typography with Status and Stage", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-form \.opportunities-extension-stage-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-status-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-activity-filter-button\s*\{([^}]+)\}/);
assert.ok(shared, "all filter badges use the same presentation rule");
for (const declaration of ["display: inline-flex;", "align-items: center;", "box-sizing: border-box;", "height: 28px;",
"padding: 4px 7px;", "border-radius: 18px;", "font-size: 11px;", "font-weight: 700;", "line-height: 18px;"]) {
assert.ok(shared[1].includes(declaration), declaration);
}
});
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => { test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8"); const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/); assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);

View File

@@ -56,7 +56,9 @@ test("CRM reports real page counts and Actions reports queued and completed requ
pending[0]({ ok: true, actions: [], hasNext: false }); pending[0]({ ok: true, actions: [], hasNext: false });
await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]); assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]);
pending[1]({ ok: false, error: "Synthetic failure" }); for (let attempt = 1; attempt <= 5; attempt += 1) {
await new Promise(resolve => setImmediate(resolve)); pending[attempt]({ ok: false, error: "Synthetic failure" });
await new Promise(resolve => setImmediate(resolve));
}
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]); assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
}); });

View File

@@ -0,0 +1,108 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const { response } = require("./support/characterization.cjs");
function setup(fetch) {
const app = loadApplication("content", { fetch });
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.debugContentView.renderDebugPanel = () => {};
app.activityProgress.update = () => {};
app.productsContentJobs.renderProductsProgress = () => {};
app.productsContentJobs.queueProductsTableRender = () => {};
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
const actions = [];
app.platformContentMessages.sendRuntimeMessage = async message => {
actions.push(message);
return { ok: true, actions: [], hasNext: false };
};
async function cycle(id) {
app.productsContentState.currentProductsRequestId = id;
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }], cachedAt: Date.now(), fromCache: false });
app.productActivities.begin(id, "synthetic");
await app.productActivities.complete(id);
}
return { app, actions, cycle };
}
test("SR succeeds on fifth attempt, is shared in flight and reused across Reference/product reloads", async () => {
let calls = 0;
const h = setup(async () => {
calls += 1;
if (calls < 5) throw new Error("Synthetic offline");
return response({ items: [], hasMore: false });
});
const first = h.app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.equal(h.app.opportunityCrmServiceRequests.load("synthetic", 1), first);
assert.equal(await first, true);
assert.equal(calls, 5);
await h.cycle("current-quarter");
h.app.opportunitiesQueryState.periodRequestVersion += 1;
await h.cycle("next-quarter");
assert.equal(calls, 5);
assert.equal(h.actions.length, 2);
assert.equal(h.app.debugState.requestLog.filter(entry => entry.name === "resquestCrmServiceRequest").length, 5);
h.app.opportunityCrmServiceRequests.reset();
await h.cycle("explicit-refresh");
assert.equal(calls, 6);
});
for (const failure of [() => new Response("Denied", { status: 403 }), () => new Response("<html>Login</html>")]) {
test("SR exhausting five attempts blocks Actions and preserves the last completed timestamp", async () => {
let calls = 0;
const h = setup(async () => { calls += 1; return failure(); });
h.app.productsContentState.productsLastCachedAt = 123;
await h.cycle("failed");
assert.equal(calls, 5);
assert.equal(h.actions.length, 0);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").state, "error");
assert.equal(h.app.productsContentState.productsLastCachedAt, 123);
h.app.authSessionState.principal = "synthetic@example.invalid";
assert.equal(h.app.opportunityWorkloadActions.getSummary("A").state, "error");
});
}
test("pagination retries only the failed SR page, with no duplicate records", async () => {
const offsets = [];
let failures = 0;
const h = setup(async (_url, options) => {
const { offset } = JSON.parse(options.body);
offsets.push(offset);
if (offset === 1 && failures++ < 2) return response({ invalid: true });
return response({ items: [{ SrNumber: `SR${offset}`, ReportedHours_c: 2, OpportunityName_Id_c: { OptyNumber: "A" } }],
offset, count: 1, hasMore: offset === 0 });
});
await h.cycle("paged");
assert.deepEqual(offsets, [0, 1, 1, 1]);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").reportedHours, 4);
assert.equal(h.actions.length, 1);
});
test("Actions retries each workload up to five times and stops as soon as it succeeds", async () => {
const h = setup(async () => response({ items: [], hasMore: false }));
let attempts = 0;
h.app.platformContentMessages.sendRuntimeMessage = async () => {
attempts += 1;
return attempts < 3 ? { ok: false, error: "Synthetic" } : { ok: true, actions: [], hasNext: false };
};
await h.cycle("success");
assert.equal(attempts, 3);
attempts = 0;
h.app.platformContentMessages.sendRuntimeMessage = async () => { attempts += 1; throw new Error("Synthetic"); };
await h.cycle("failed-actions");
assert.equal(attempts, 5);
});
test("an invalidated SR request does not continue retrying or launch Actions", async () => {
let reject, calls = 0;
const h = setup(() => { calls += 1; return new Promise((_resolve, fail) => { reject = fail; }); });
const pending = h.cycle("old");
await new Promise(resolve => setImmediate(resolve));
h.app.opportunityCrmServiceRequests.reset();
h.app.productsContentState.currentProductsRequestId = "new";
reject(new Error("Synthetic"));
await pending;
assert.equal(calls, 1);
assert.equal(h.actions.length, 0);
});

View File

@@ -29,6 +29,7 @@ function setup(mockSalesToken = true) {
const context = { const context = {
document: { document: {
createElement: (tag) => new Element(tag), createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
querySelectorAll: () => visibleAlerts querySelectorAll: () => visibleAlerts
}, },
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })), fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),

View File

@@ -0,0 +1,68 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function setup() {
const app = loadApplication("content");
const normal = { OptyNumber: "normal", Name: "Synthetic", ForecastGroup_c: "FORECAST", StatusCode: "OPEN",
CustomerAccount: { PartyId: "1", PartyUniqueName: "Customer" }, Owner: { PersonFirstName: "Owner" } };
const items = [normal,
{ ...normal, OptyNumber: "stage", ForecastGroup_c: null },
{ ...normal, OptyNumber: "status", StatusCode: "UNLISTED" },
{ ...normal, OptyNumber: "customer", CustomerAccount: null },
{ ...normal, OptyNumber: "owner", Owner: null }];
app.opportunitiesTableState.opportunitiesTableState.items = items;
app.opportunitiesFilters.resetCustomerFilter(items);
app.opportunitiesFilters.resetOwnerFilter(items);
return { app, items, result: () => Array.from(app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber) };
}
test("all selected includes records with missing or unlisted dimensions", () => {
const h = setup();
assert.equal(h.result().length, h.items.length);
h.app.opportunitiesFilterState.tableSearch = "normal";
assert.deepEqual(h.result(), ["normal"], "search still restricts results");
});
test("partial and empty selections remain restrictive, including WON_PENDING mapping", () => {
const h = setup();
const state = h.app.opportunitiesFilterState;
state.selectedStages.delete("SQL");
assert.equal(h.result().includes("stage"), false);
state.selectedStages.add("SQL");
state.selectedStatuses.delete("CLOSED");
assert.equal(h.result().includes("status"), false);
assert.equal(h.app.opportunitiesFilters.matchesSelectedStatus("WON_PENDING"), true);
state.selectedStatuses.clear();
assert.deepEqual(h.result(), []);
});
test("customer and owner exclusions apply only when a narrower selection is made", () => {
const h = setup();
const state = h.app.opportunitiesFilterState;
state.selectedCustomerKeys.clear();
assert.deepEqual(h.result(), []);
state.selectedCustomerKeys.add("party:1");
assert.equal(h.result().includes("customer"), true);
state.selectedOwnerKeys.clear();
assert.deepEqual(h.result(), []);
});
test("Owner offers one dash option for unassigned records and supports selecting it alone", () => {
const h = setup();
for (const Owner of [undefined, {}, "", " ", { PersonFirstName: " " }]) {
h.items.push({ ...h.items[0], OptyNumber: `missing-${h.items.length}`, Owner });
}
const options = Array.from(h.app.opportunitiesFormatters.getOwnerOptions());
const missing = options.filter(option => option.label === "-");
assert.equal(missing.length, 1);
assert.equal(missing[0].key, "owner:unassigned");
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set([missing[0].key]);
assert.equal(h.result().length, 6);
assert.equal(h.result().includes("normal"), false);
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set(options.map(option => option.key));
assert.equal(h.result().length, h.items.length);
h.app.opportunitiesFilterState.selectedOwnerKeys.delete(missing[0].key);
assert.equal(h.result().includes("owner"), false);
assert.equal(h.result().length, 4);
});

113
tests/badgeIcons.test.cjs Normal file
View File

@@ -0,0 +1,113 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication, composeScript } = require("./support/application.cjs");
const root = path.join(__dirname, "..");
class Element {
constructor(tag) {
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = "";
this.classList = { toggle() {} };
}
set textContent(value) { this.text = String(value); this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...children) { this.children.push(...children); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, callback) { this.listeners[key] = callback; }
}
function setup() {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), getElementById: () => null,
createElementNS: (namespace, tag) => Object.assign(new Element(tag), { namespace }) } });
app.currentUser.allowsCrmActivities = () => true;
app.productsContentJobs.queueProductsTableRender = () => {};
app.activityProgress.update = () => {};
app.authSessionState.principal = "me@example.invalid";
return app;
}
function checkIcon(badge, kind, value) {
assert.equal(badge.textContent, value);
const images = badge.children.filter(child => child.tag === "svg");
assert.equal(images.length, 1);
const icon = images[0];
assert.equal(icon.namespace, "http://www.w3.org/2000/svg");
assert.equal(icon.attributes.viewBox, "0 0 24 24");
assert.equal(icon.attributes.fill, "none");
assert.equal(icon.attributes.stroke, "currentColor");
assert.equal(icon.attributes["stroke-width"], "2.25");
assert.equal(icon.attributes["stroke-linecap"], "round");
assert.equal(icon.attributes["stroke-linejoin"], "round");
assert.equal(icon.attributes["data-badge-icon"], kind);
assert.equal(icon.attributes["aria-hidden"], "true");
assert.equal(icon.attributes.focusable, "false");
assert.equal(icon.attributes.width, "16");
assert.equal(icon.attributes.height, "16");
assert.ok(icon.children.length >= 3);
assert.ok(icon.children.every(child => ["path", "rect", "polyline"].includes(child.tag)));
assert.ok(icon.children.every(child => child.namespace === icon.namespace));
assert.equal(badge.children.some(child => child.tag === "img"), false);
}
test("consistent inline vectors replace raster icons without parentheses or lost tooltips", async () => {
const app = setup();
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123" })) };
const count = app.productsDetails.createProductsCountButton("TEST", products);
checkIcon(count, "products", "3");
assert.equal(count.title, "3 products");
assert.equal(typeof count.listeners.click, "function");
const ramp = app.productsDetails.createProductsRampBadge(products);
checkIcon(ramp, "ramp", "1,2,3");
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
checkIcon(missingRamp, "ramp", "N/D");
assert.equal(missingRamp.attributes["data-missing"], "true");
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
{ SrNumber: "SR-TEST", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST" } }
];
await app.opportunityCrmServiceRequests.load("synthetic", app.opportunitiesQueryState.periodRequestVersion);
const sr = app.opportunityCrmServiceRequests.createBadge("TEST");
checkIcon(sr, "sr", "4h");
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
assert.equal(typeof sr.listeners.click, "function");
checkIcon(app.opportunityCrmServiceRequests.createBadge("OTHER"), "sr", "0h");
app.productsContentState.opptyProductsByNumber.set("TEST", products);
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, hasNext: false, actions: [
{ id: 1, owner: "me@example.invalid", name: "My action" },
{ id: 2, owner: "other@example.invalid", name: "Other action" }
] });
await app.opportunityWorkloadActions.loadProducts(products);
const actions = app.opportunityWorkloadActions.createBadge("TEST");
checkIcon(actions, "actions", "2");
assert.equal(actions.attributes["data-state"], "matched");
assert.match(actions.title, /My action/);
assert.match(actions.title, /Other action/);
app.currentUser.allowsCrmActivities = () => false;
assert.equal(app.opportunityWorkloadActions.createBadge("TEST"), null);
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST"), null);
});
test("vector shapes and their license ship in both distributions without raster images or requests", () => {
const source = composeScript("content");
const module = fs.readFileSync(path.join(root, "src/features/opportunities/badge-content.js"), "utf8");
assert.ok(source.includes(module));
assert.doesNotMatch(source, /opportunityBadgeImages|data:image\/png;base64/);
for (const target of ["chromium", "firefox"]) {
const bundle = fs.readFileSync(path.join(root, "dist", target, "content.js"), "utf8");
assert.ok(bundle.includes(module));
assert.ok(bundle.includes("Copyright (c) 2026 Lucide Icons and Contributors"));
}
});
test("icon styles preserve uniform dimensions and provide light, dark and active contrast", () => {
const css = fs.readFileSync(path.join(root, "src/features/opportunities/styles.css"), "utf8");
const icon = css.match(/\.opportunities-extension-badge-icon \{([^}]+)\}/)[1];
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "color: inherit;", "stroke: currentColor;", "stroke-width: 2.25;"]) {
assert.ok(icon.includes(rule), rule);
}
assert.doesNotMatch(icon, /filter:|mix-blend-mode:/);
assert.match(css, /gap: 4px;\s*height: 20px;/);
assert.match(css, /cell-actions > \.opportunities-extension-crm-sr-badge \{\s*display: inline-flex;\s*align-items: center;\s*justify-content: flex-start;/);
});

View File

@@ -0,0 +1,73 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const tokens = [
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } },
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-actions" } }
];
const request = { type: "opportunitiesExtension.requestConsumerTokenService" };
for (const family of ["chrome", "firefox"]) {
for (const status of [401, 403]) {
test(`${family}: HTTP ${status} opens an inactive SPA session and closes it after valid JSON`, async () => {
let attempts = 0;
const h = loadBackground({ family, fetch: async (_url, options) => {
attempts += 1;
assert.equal(options.credentials, "include");
if (attempts === 1) return response({ error: "Session unavailable" }, status);
assert.equal(h.tabs.length, 1, "session tab must open before retrying");
return response(tokens);
} });
const result = await h.dispatch(request);
assert.equal(result.ok, true);
assert.equal(attempts, 2);
assert.equal(h.tabs.length, 1);
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html");
assert.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[0].httpStatus, status);
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "closed-after-valid-json");
assert.equal(result.authorizations.workloadActions.value, "synthetic-actions");
});
}
test(`${family}: persistent 403 stops after five attempts and removes the auxiliary tab`, async () => {
let attempts = 0;
const h = loadBackground({ family, fetch: async () => { attempts += 1; return response({}, 403); } });
const result = await h.dispatch(request);
assert.equal(result.ok, false);
assert.equal(attempts, 5);
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removedTabs, [91]);
assert.match(result.error, /403 after 5 attempts/);
assert.equal(result.debugRequests.at(-1).metadata.retryLimitReached, "true");
assert.equal(result.debugRequests.at(-1).metadata.sessionBootstrapState, "closed-after-retry-finished");
});
}
test("mixed 403/HTML recovery shares one tab and the existing retry budget", async () => {
let attempts = 0;
const h = loadBackground({ fetch: async () => {
attempts += 1;
if (attempts === 1) return response({}, 403);
if (attempts === 2) return new Response("<html>Login</html>");
return response(tokens);
} });
const result = await h.dispatch(request);
assert.equal(result.ok, true);
assert.equal(attempts, 3);
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session");
});
test("unrelated HTTP failures do not launch the SPA session page", async () => {
const h = loadBackground({ fetch: async () => response({}, 500) });
const result = await h.dispatch(request);
assert.equal(result.ok, false);
assert.equal(result.debugRequests.length, 1);
assert.equal(h.tabs.length, 0);
});

View File

@@ -3,7 +3,8 @@ const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs"); const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 }); const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) { function setup(fetch) {
const document = { createElement: () => ({ attributes: {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) }; const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { fetch, document, Intl, Date }); const app = loadApplication("content", { fetch, document, Intl, Date });
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
app.debugContentView.renderDebugPanel = () => {}; app.debugContentView.renderDebugPanel = () => {};
@@ -18,8 +19,12 @@ test("SR badge shows reported hours, sums linked SRs and preserves missing value
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) { for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) {
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false })); const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1); await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, `\u2705 SR (${expected})`); assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR"); assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").title, "Whether a Service Request has been created for the opportunities.");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").title, "Whether a Service Request has been created for the opportunities.");
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").attributes["data-state"], "matched");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").attributes["data-state"], "missing");
} }
}); });
@@ -89,11 +94,11 @@ test("CRM query preserves payload and pagination after opportunities, uses token
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests"); assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
} }
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1"); const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
assert.equal(found.textContent, "\u2705 SR (0h)"); assert.equal(found.textContent, "0h");
assert.equal(found.attributes["data-state"], "matched"); assert.equal(found.attributes["data-state"], "matched");
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"]) for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
assert.ok(found.attributes["aria-label"].includes(detail)); assert.ok(found.attributes["aria-label"].includes(detail));
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR"); assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null); assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
app.opportunityCrmServiceRequests.reset(); app.opportunityCrmServiceRequests.reset();
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null); assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
@@ -118,7 +123,7 @@ test("CRM failures do not mislabel missing SRs or block the opportunity list", a
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1); await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
assert.equal(loaded, true); assert.equal(loaded, true);
await app.opportunityCrmServiceRequests.load("token", 1); await app.opportunityCrmServiceRequests.load("token", 1);
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "SR ?"); assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "?");
assert.equal(app.debugState.requestLog.at(-1).status, "failed"); assert.equal(app.debugState.requestLog.at(-1).status, "failed");
} }
}); });
@@ -133,8 +138,8 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
await app.opportunityCrmServiceRequests.load("token", 2); await app.opportunityCrmServiceRequests.load("token", 2);
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 })); resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
await old; await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR (0h)"); assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR"); assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
assert.equal(app.debugState.requestLog.length, 1); assert.equal(app.debugState.requestLog.length, 1);
}); });
@@ -155,8 +160,8 @@ test("Reference changes share pending pagination and reuse completed CRM results
app.opportunitiesQueryState.periodRequestVersion = 3; app.opportunitiesQueryState.periodRequestVersion = 3;
await app.opportunityCrmServiceRequests.load("token", 3); await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 2); assert.equal(calls, 2);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR (0h)"); assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR (0h)"); assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "0h");
app.opportunityCrmServiceRequests.reset(); app.opportunityCrmServiceRequests.reset();
await app.opportunityCrmServiceRequests.load("token", 3); await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 3); assert.equal(calls, 3);

View File

@@ -0,0 +1,48 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor() { this.children = []; this.attributes = {}; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener() {}
}
test("Total Opportunities displays filtered/overall and updates with filters and Reference data", () => {
const dashboard = new Element(), table = new Element();
const app = loadApplication("content", {
document: {
createElement: () => new Element(),
getElementById: id => id === "opportunities-extension-stage-dashboard" ? dashboard
: id === "opportunities-extension-table" ? table : null
},
performance: { now: () => 0 }, requestAnimationFrame: tick => tick(480)
});
// Use real table/dashboard coordination without constructing unrelated row controls.
app.opportunitiesTableState.opportunitiesTableState.status = "loading";
const items = Array.from({ length: 26 }, () => ({ ForecastGroup_c: "FORECAST" }));
app.opportunitiesTableState.opportunitiesTableState.items = items;
let filtered = items.slice(0, 11);
app.opportunitiesFilters.getSortedOpportunities = () => filtered;
const value = () => dashboard.children[0].children[1];
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "11/26");
assert.equal(value().attributes["aria-label"], "11 filtered opportunities out of 26 total");
filtered = [];
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "0/26");
filtered = items;
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "26/26");
app.opportunitiesTableState.opportunitiesTableState.items = items.slice(0, 3);
filtered = items.slice(0, 2);
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "2/3");
app.opportunitiesTableState.opportunitiesTableState.items = [];
filtered = [];
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "0/0");
});

View File

@@ -59,6 +59,7 @@ test("ten columns retain their links and put product/customer controls below the
assert.ok(name.children[0].href.endsWith("TEST-1")); assert.ok(name.children[0].href.endsWith("TEST-1"));
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count"); assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
assert.equal(name.children[1].children[0].textContent, "1"); assert.equal(name.children[1].children[0].textContent, "1");
assert.equal(name.children[1].children[0].title, "1 product");
assert.equal(customer.children[0].textContent, "Synthetic customer"); assert.equal(customer.children[0].textContent, "Synthetic customer");
assert.ok(customer.children[0].href.endsWith("party%2Fid")); assert.ok(customer.children[0].href.endsWith("party%2Fid"));
assert.equal(customer.children[1].children[0].title, "View customer consumption"); assert.equal(customer.children[1].children[0].title, "View customer consumption");
@@ -82,8 +83,8 @@ test("ramp months preserve zero and flag missing values beside the product count
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp"); const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
assert.equal(badge(), undefined); assert.equal(badge(), undefined);
for (const [values, expected] of [ for (const [values, expected] of [
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"], [[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 0,3,N/D,6,3"] [[0], "0"], [[0, "3", null, " 6 ", 3], "0,3,N/D,6,3"]
]) { ]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c })) fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
@@ -93,10 +94,13 @@ test("ramp months preserve zero and flag missing values beside the product count
else { else {
assert.equal(badge().textContent, expected); assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D"))); assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.match(badge().title, /^Ramp Months:/); assert.equal(badge().title, `Ramp Months: ${expected.split(",").join(", ")}`);
assert.equal(badge().attributes["aria-label"], badge().title);
assert.equal(h.cell("name").children[0].children[1].children[1], badge()); assert.equal(h.cell("name").children[0].children[1].children[1], badge());
} }
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count").textContent, String(values.length)); const count = findClass(h.cell("name"), "opportunities-extension-products-count");
assert.equal(count.textContent, String(values.length));
assert.equal(count.title, `${values.length} product${values.length === 1 ? "" : "s"}`);
} }
}); });
@@ -118,6 +122,33 @@ test("products remain expandable with AI summary and a ten-column detail row aft
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary"); assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
}); });
test("product labels support multiple digits without changing their tooltip", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: Array.from({ length: 12 }, () => ({})) });
h.app.opportunitiesTable.renderOpportunitiesTable();
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
assert.equal(count.textContent, "12");
assert.equal(count.title, "12 products");
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
});
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
assert.ok(shared, "P, R, SR and Act share one scoped badge rule");
for (const declaration of ["display: inline-flex;", "align-items: center;", "justify-content: flex-start;",
"box-sizing: border-box;", "height: 20px;", "min-height: 20px;", "padding: 0 6px;", "margin: 0;",
"border-radius: 8px;", "font-family: inherit;", "font-size: 10px;", "font-weight: 600;", "line-height: 16px;"]) {
assert.ok(shared[1].includes(declaration), declaration);
}
const plus = css.match(/\.opportunities-extension-add-action\s*\{([^}]+)\}/)[1];
assert.match(plus, /height: 16px;/);
assert.match(plus, /align-items: center;/);
assert.match(plus, /font: inherit;/);
const actions = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/workload-actions.js"), "utf8");
assert.match(actions, /badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge"/);
});
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => { test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
const h = setup(); const h = setup();
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
@@ -125,11 +156,11 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
}]; }];
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion); await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
h.app.opportunitiesTable.renderOpportunitiesTable(); h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "\u2705 SR (0h)"); assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] }); h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.opportunitiesTable.renderOpportunitiesTable(); h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1]; const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR (0h)", "Act ?"]); assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
findClass(h.cell("name"), "opportunities-extension-products-count").click(); findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table")); assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
}); });

View File

@@ -48,7 +48,8 @@ test("Last update ignores superseded cycles and settles failures without changin
assert.equal(h.app.productsContentState.productsLastCachedAt, 100); assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
current.reject(new Error("Synthetic failure")); current.reject(new Error("Synthetic failure"));
await flush(); await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 300); assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
assert.equal(h.calls.includes("empty products"), false, "SR failure blocks Actions");
}); });
test("Last update waits for cache restoration and ignores a superseded Reference", async () => { test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
@@ -111,7 +112,7 @@ test("follow-up calls wait for completion, deduplicate terminal messages and ref
h.progress(first, { state: "complete" }); h.progress(first, { state: "complete" });
h.progress(first, { state: "complete" }); h.progress(first, { state: "complete" });
await flush(); await flush();
assert.deepEqual(h.calls, ["reset SR", "123", "SR token"]); assert.deepEqual(h.calls, ["SR token", "123"]);
const second = h.start("new-token"); const second = h.start("new-token");
h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] }); h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] });
h.progress(second, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }] }); h.progress(second, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }] });
@@ -152,13 +153,13 @@ test("cached products restore activities without network; mixed cycles fetch onl
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true }); h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(cached, { state: "complete" }); h.progress(cached, { state: "complete" });
await flush(); await flush();
assert.deepEqual(h.calls, ["reset SR"]); assert.deepEqual(h.calls, []);
const mixed = h.start(); const mixed = h.start();
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true }); h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false }); h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
h.progress(mixed, { state: "complete" }); h.progress(mixed, { state: "complete" });
await flush(); await flush();
assert.deepEqual(h.calls, ["reset SR", "reset SR", "fresh", "SR token"]); assert.deepEqual(h.calls, ["SR token", "fresh"]);
}); });
test("reopening an existing modal does not reload products or reset/query activities", async () => { test("reopening an existing modal does not reload products or reset/query activities", async () => {

View File

@@ -0,0 +1,115 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function setup() {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.isConnected = true; this.text = ""; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...nodes) { for (const node of nodes) { node.parent = this; this.children.push(node); } }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
}
const host = new Element("host");
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
getElementById: id => id === "opportunities-extension-modal" ? host : null,
listeners: {}, addEventListener(key, fn) { this.listeners[key] = fn; } };
const app = loadApplication("content", { document, window: {} });
app.currentUser.allowsCrmActivities = () => true;
app.authSessionState.principal = "synthetic@example.invalid";
app.productsContentState.opptyProductsByNumber.set("A", { items: [] });
app.productsContentState.opptyProductsByNumber.set("B", { items: [] });
app.platformContentMessages.sendRuntimeMessage = () => assert.fail("Form must not send requests");
const walk = node => [node, ...node.children.flatMap(walk)];
const nodes = () => walk(host);
const form = () => nodes().find(node => node.tag === "form");
return { app, host, document, nodes, form };
}
test("only plus on the missing Action badge opens a form with six fields and no save operation", () => {
const h = setup();
const badge = h.app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "0+");
badge.click();
assert.equal(h.host.children.length, 0);
const plus = badge.children.find(node => node.tag === "button");
assert.equal(plus.textContent, "+");
assert.equal(plus.attributes["aria-haspopup"], "dialog");
plus.click();
assert.equal(h.host.children.length, 1);
assert.equal(h.host.children[0].attributes.role, "dialog");
assert.deepEqual(h.nodes().filter(node => node.name).map(node => node.name), ["team", "startDate", "endDate", "status", "complexity", "notes"]);
assert.equal(h.nodes().find(node => node.name === "notes").tag, "textarea");
assert.equal(h.nodes().find(node => node.name === "startDate").type, "date");
assert.equal(h.document.activeElement.name, "team");
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, true);
let prevented = false;
h.form().listeners.submit({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
h.nodes().find(node => node.textContent === "Cancel").click();
assert.equal(h.host.children.length, 0);
assert.equal(h.document.activeElement, plus);
});
test("form traps focus, handles Escape, replaces rather than duplicates, and resets its fields", () => {
const h = setup();
h.app.contentModal.ensureEscapeKeyHandler();
const first = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
first.click();
h.nodes().find(node => node.name === "notes").value = "Draft";
const second = h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button");
second.click();
assert.equal(h.host.children.length, 1);
assert.equal(h.nodes().find(node => node.name === "notes").value, undefined);
const cancel = h.nodes().find(node => node.textContent === "Cancel");
cancel.focus();
let prevented = false;
h.host.children[0].listeners.keydown({ key: "Tab", preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(h.document.activeElement.title, "Close");
h.document.listeners.keydown({ key: "Escape", preventDefault() {} });
assert.equal(h.host.children.length, 0);
assert.equal(h.document.activeElement, second);
});
test("SALES_REP, loading/unknown Actions and stale triggers cannot open the modal", () => {
const h = setup();
const plus = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
h.app.currentUser.allowsCrmActivities = () => false;
plus.click();
assert.equal(h.host.children.length, 0);
assert.equal(h.app.opportunityWorkloadActions.createBadge("A"), null);
h.app.currentUser.allowsCrmActivities = () => true;
h.app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "pending" }] });
plus.click();
assert.equal(h.host.children.length, 0);
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").children.some(node => node.tag === "button"), false);
h.app.authSessionState.principal = "";
assert.equal(h.app.opportunityWorkloadActions.createBadge("B").children.some(node => node.tag === "button"), false);
});
test("resetting Actions closes an open form without leaving an old opportunity context", () => {
const h = setup();
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
h.app.opportunityWorkloadActions.reset();
assert.equal(h.host.children.length, 0);
});
test("Action header shows the selected opportunity customer, name and formatted close date safely", () => {
const h = setup();
h.app.opportunitiesTableState.opportunitiesTableState.items = [
{ OptyNumber: "A", Name: "<script>synthetic</script>", CustomerAccount: { PartyUniqueName: "Customer A" }, EffectiveDate: "2026-11-30" },
{ OptyNumber: "B", Name: "Opportunity B", CustomerAccount: { PartyUniqueName: "Customer B" }, EffectiveDate: null }
];
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Name", "Close Date"]);
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer A", "<script>synthetic</script>", "30/11/2026"]);
assert.equal(h.nodes().some(node => node.tag === "script"), false);
h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button").click();
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer B", "Opportunity B", "-"]);
});

View File

@@ -52,7 +52,9 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO
}); });
function contentFixture(send) { function contentFixture(send) {
const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } }); const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(k, v) { this.attributes[k] = v; } }) };
document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { document });
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } }); app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
app.authSessionState.principal = "test@example.invalid"; app.authSessionState.principal = "test@example.invalid";
@@ -74,12 +76,14 @@ test("Actions completion includes queued calls, shared pending records, failures
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; }); const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
await flush(); await flush();
assert.equal(pending.length, 4); assert.equal(pending.length, 4);
pending.slice(0, 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" })); for (let attempt = 0; attempt < 5; attempt += 1) {
await flush(); pending.slice(attempt * 4, attempt * 4 + 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
assert.equal(pending.length, 5); await flush();
}
assert.equal(pending.length, 21);
assert.equal(complete, false); assert.equal(complete, false);
assert.equal(sharedComplete, false); assert.equal(sharedComplete, false);
pending[4]({ ok: true, actions: [], hasNext: false }); pending[20]({ ok: true, actions: [], hasNext: false });
await flush(); await flush();
assert.equal(complete, false, "cache write still pending"); assert.equal(complete, false, "cache write still pending");
finishWrite(); finishWrite();
@@ -109,18 +113,18 @@ test("actions aggregate all products, share RevnId queries across Reference chan
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] }; const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products); app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products); app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ..."); assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "...");
await flush(); await flush();
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
assert.equal(calls[0].authorization.value, "synthetic-wwb"); assert.equal(calls[0].authorization.value, "synthetic-wwb");
const badge = app.opportunityWorkloadActions.createBadge("A"); const badge = app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "\u2705 Act"); assert.equal(badge.textContent, "1");
assert.match(badge.title, /<script>safe<\/script>/); assert.match(badge.title, /<script>safe<\/script>/);
app.opportunityWorkloadActions.loadProducts(products); app.opportunityWorkloadActions.loadProducts(products);
await flush(); await flush();
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] }); app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "\u26d4 Act"); assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "0");
app.opportunityWorkloadActions.reset(false); app.opportunityWorkloadActions.reset(false);
app.opportunityWorkloadActions.loadProducts(products); app.opportunityWorkloadActions.loadProducts(products);
await flush(); await flush();
@@ -138,9 +142,9 @@ test("errors, missing ids and partial empty pages never appear as confirmed abse
app.productsContentState.opptyProductsByNumber.set("A", products); app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products); app.opportunityWorkloadActions.loadProducts(products);
await flush(); await flush();
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?"); assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] }); app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?"); assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
} }
}); });
@@ -155,15 +159,17 @@ test("Act requires an owner matching tokenrelay principal while tooltip retains
app.opportunityWorkloadActions.loadProducts(products); app.opportunityWorkloadActions.loadProducts(products);
await flush(); await flush();
const badge = () => app.opportunityWorkloadActions.createBadge("A"); const badge = () => app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge().textContent, "\u2705 Act"); assert.equal(badge().textContent, "2");
assert.equal(badge().attributes["data-state"], "matched");
assert.match(badge().title, /Other action/); assert.match(badge().title, /Other action/);
assert.match(badge().title, /My action/); assert.match(badge().title, /My action/);
app.authSessionState.principal = "different@example.invalid"; app.authSessionState.principal = "different@example.invalid";
assert.equal(badge().textContent, "\u26d4 Act", "ownerEmail must not determine ownership"); assert.equal(badge().textContent, "2");
assert.equal(badge().attributes["data-state"], "missing", "ownerEmail must not determine ownership");
assert.match(badge().title, /Other action/); assert.match(badge().title, /Other action/);
assert.match(badge().title, /My action/); assert.match(badge().title, /My action/);
app.authSessionState.principal = ""; app.authSessionState.principal = "";
assert.equal(badge().textContent, "Act ?"); assert.equal(badge().textContent, "?");
}); });
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => { test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {