Replaced labels with icons in the SR and Action badges
This commit is contained in:
26
README.md
26
README.md
@@ -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.
|
||||
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
|
||||
`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`
|
||||
query endpoint, independently of the opportunity query version. Calls appear in
|
||||
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
|
||||
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;
|
||||
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,
|
||||
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.
|
||||
Closing and reopening the modal preserves results and does not
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
@@ -78,7 +96,11 @@ with both positive and negative options selected, all activity states remain
|
||||
visible. These selections are kept in memory, not persisted.
|
||||
|
||||
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
|
||||
aggregates all products: green means an action's `owner` matches the general
|
||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||
|
||||
21
dist/chromium/background.js
vendored
21
dist/chromium/background.js
vendored
@@ -250,14 +250,22 @@ function createAuthConsumerSession({
|
||||
const isHtmlFallback = error
|
||||
&& error.code === "INVALID_JSON_RESPONSE"
|
||||
&& 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];
|
||||
|
||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.attempt = String(attempt);
|
||||
if (isHtmlFallback) {
|
||||
if (needsSessionRecovery) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -285,7 +293,7 @@ function createAuthConsumerSession({
|
||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||
if (debugEntry) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -874,7 +882,10 @@ function createHttpBackgroundJson({
|
||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||
|
||||
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) {
|
||||
|
||||
352
dist/chromium/content.js
vendored
352
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
21
dist/firefox/background.js
vendored
21
dist/firefox/background.js
vendored
@@ -250,14 +250,22 @@ function createAuthConsumerSession({
|
||||
const isHtmlFallback = error
|
||||
&& error.code === "INVALID_JSON_RESPONSE"
|
||||
&& 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];
|
||||
|
||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.attempt = String(attempt);
|
||||
if (isHtmlFallback) {
|
||||
if (needsSessionRecovery) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -285,7 +293,7 @@ function createAuthConsumerSession({
|
||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||
if (debugEntry) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -874,7 +882,10 @@ function createHttpBackgroundJson({
|
||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||
|
||||
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) {
|
||||
|
||||
352
dist/firefox/content.js
vendored
352
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@ module.exports = {
|
||||
"src/features/opportunities/dashboard-state.js",
|
||||
"src/features/debug/state.js",
|
||||
"src/features/opportunities/columns.js",
|
||||
"src/features/opportunities/badge-content.js",
|
||||
"src/content/fusion-page.js",
|
||||
"src/content/modal.js",
|
||||
"src/content/theme.js",
|
||||
@@ -39,6 +40,7 @@ module.exports = {
|
||||
"src/features/opportunities/activity-filters.js",
|
||||
"src/features/opportunities/crm-sr-popover.js",
|
||||
"src/features/opportunities/workload-actions.js",
|
||||
"src/features/opportunities/workload-action-form.js",
|
||||
"src/features/opportunities/fiscal-periods.js",
|
||||
"src/features/opportunities/table.js",
|
||||
"src/features/opportunities/dashboard.js",
|
||||
|
||||
@@ -103,14 +103,22 @@ function createAuthConsumerSession({
|
||||
const isHtmlFallback = error
|
||||
&& error.code === "INVALID_JSON_RESPONSE"
|
||||
&& 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];
|
||||
|
||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||
if (debugEntry) {
|
||||
debugEntry.metadata.attempt = String(attempt);
|
||||
if (isHtmlFallback) {
|
||||
if (needsSessionRecovery) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
|
||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||
if (debugEntry) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const contentModal = createContentModal({
|
||||
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
@@ -220,7 +221,9 @@ function createContentApplication(overrides = {}) {
|
||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||
});
|
||||
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
|
||||
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
activityCache,
|
||||
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
@@ -356,6 +359,7 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
||||
const activityFilters = createActivityFilters({
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
|
||||
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
|
||||
@@ -364,9 +368,9 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const productActivities = createProductActivities({
|
||||
productsContentState, opportunitiesQueryState,
|
||||
resetCrm: () => opportunityCrmServiceRequests.reset(),
|
||||
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
|
||||
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
||||
blockActions: products => opportunityWorkloadActions.blockProducts(products),
|
||||
render: () => {
|
||||
productsContentJobs.renderProductsProgress();
|
||||
productsContentJobs.queueProductsTableRender();
|
||||
@@ -386,7 +390,19 @@ function createContentApplication(overrides = {}) {
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
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({
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
createActionButton: number => workloadActionForm.createButton(number),
|
||||
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||
activityCache,
|
||||
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
@@ -398,6 +414,7 @@ function createContentApplication(overrides = {}) {
|
||||
productsContentState, messageTypes, environment
|
||||
});
|
||||
const productsDetails = createProductsDetails({
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
@@ -561,6 +578,7 @@ function createContentApplication(overrides = {}) {
|
||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||
}
|
||||
return {
|
||||
workloadActionForm,
|
||||
activityFilters,
|
||||
start, contracts,
|
||||
opportunityWorkloadActions,
|
||||
|
||||
@@ -28,6 +28,7 @@ function createContentModal({
|
||||
closeAccountPlansModal,
|
||||
closeInstallBaseModal,
|
||||
closeServiceRequestsModal,
|
||||
closeActionForm,
|
||||
closeCustomerFilterPanel,
|
||||
closeOwnerFilterPanel,
|
||||
closePreferencesModal,
|
||||
@@ -470,6 +471,7 @@ function createContentModal({
|
||||
closeAccountPlansModal();
|
||||
closeInstallBaseModal();
|
||||
closeServiceRequestsModal();
|
||||
closeActionForm(false);
|
||||
modal.hidden = true;
|
||||
}
|
||||
|
||||
@@ -498,6 +500,10 @@ function createContentModal({
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (closeActionForm()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (closeServiceRequestsModal()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -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"]) };
|
||||
let container = null;
|
||||
let form = null;
|
||||
@@ -12,24 +12,32 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
|
||||
container = environment.document.createElement("div");
|
||||
container.className = "opportunities-extension-badge-filters";
|
||||
for (const [kind, title, options] of [
|
||||
["sr", "Service Request", [["matched", "\u2705 SR"], ["under4", "\u2705 SR < 4h"], ["missing", "\u26d4 SR"]]],
|
||||
["actions", "Actions", [["matched", "\u2705 Action"], ["missing", "\u26d4 Action"]]]
|
||||
["sr", "Service Request", ["matched", "under4", "missing"]],
|
||||
["actions", "Act", ["matched", "missing"]]
|
||||
]) {
|
||||
const section = environment.document.createElement("section");
|
||||
section.className = "opportunities-extension-activity-filter";
|
||||
const label = environment.document.createElement("span");
|
||||
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");
|
||||
controls.className = "opportunities-extension-activity-filter-controls";
|
||||
controls.setAttribute("role", "group");
|
||||
controls.setAttribute("aria-label", `Filter by ${title}`);
|
||||
for (const [value, text] of options) {
|
||||
for (const value of options) {
|
||||
const button = environment.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-activity-filter-button";
|
||||
button.setAttribute("data-value", value);
|
||||
button.textContent = text;
|
||||
if (value === "under4") button.title = "At least one SR with reported hours below 4; replaces other SR filters.";
|
||||
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
|
||||
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));
|
||||
buttons.push({ button, kind, value });
|
||||
controls.append(button);
|
||||
|
||||
63
src/features/opportunities/badge-content.js
Normal file
63
src/features/opportunities/badge-content.js
Normal 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 };
|
||||
}
|
||||
@@ -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 byOpportunity = new Map();
|
||||
let pending = null;
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
async function run(token, version, currentGeneration, cacheOptions) {
|
||||
@@ -14,8 +22,8 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
status = "loading";
|
||||
try {
|
||||
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||
if (!isActive()) return;
|
||||
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return; }
|
||||
if (!isActive()) return false;
|
||||
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
|
||||
let items = cached?.items;
|
||||
if (!items) {
|
||||
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()) return;
|
||||
if (!items || !isActive()) return false;
|
||||
const grouped = new Map();
|
||||
for (const item of items) {
|
||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||
@@ -34,10 +42,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
grouped.get(key).push(item);
|
||||
}
|
||||
byOpportunity = grouped;
|
||||
loadedItems = items;
|
||||
status = "ready";
|
||||
if (!cached) reportProgress("complete", items.length, items.length);
|
||||
return true;
|
||||
} catch {
|
||||
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function getSummary(optyNumber) {
|
||||
@@ -62,7 +73,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
const reportedHours = summary.reportedHours !== null
|
||||
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||
: "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 descriptions = items.map(item => [
|
||||
`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.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||
badge.setAttribute("aria-label", description);
|
||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||
else badge.title = description;
|
||||
badge.tabIndex = 0;
|
||||
return badge;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
|
||||
environment
|
||||
}) {
|
||||
const { document, requestAnimationFrame, performance } = environment;
|
||||
function renderStageDashboard(items) {
|
||||
function renderStageDashboard(items, totalCount = items.length) {
|
||||
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||
|
||||
if (!dashboard) {
|
||||
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
|
||||
totalCard.setAttribute("data-stage", "TOTAL");
|
||||
totalLabel.textContent = "Total Opportunities";
|
||||
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(
|
||||
totalValue,
|
||||
"TOTAL",
|
||||
items.length,
|
||||
(amount) => Math.round(amount).toLocaleString()
|
||||
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
|
||||
);
|
||||
totalCard.append(totalLabel, totalValue);
|
||||
dashboard.append(totalCard);
|
||||
|
||||
@@ -336,11 +336,14 @@ function createOpportunitiesFilters({
|
||||
|
||||
function getSortedOpportunities() {
|
||||
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) => {
|
||||
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
|
||||
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
|
||||
&& matchesSelectedStatus(item.StatusCode)
|
||||
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
|
||||
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
|
||||
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||
&& matchesTableSearch(item, normalizedSearch)
|
||||
&& matchesActivities(item);
|
||||
});
|
||||
@@ -393,6 +396,7 @@ function createOpportunitiesFilters({
|
||||
}
|
||||
|
||||
function matchesSelectedStatus(statusCode) {
|
||||
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
|
||||
if (statusCode === "WON_PENDING") {
|
||||
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
|
||||
|
||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||
const key = getOwnerKey(item);
|
||||
const label = getOwnerName(item);
|
||||
const label = String(getOwnerName(item)).trim() || "-";
|
||||
|
||||
if (key && label && !optionsByKey.has(key)) {
|
||||
optionsByKey.set(key, { key, label: String(label) });
|
||||
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
|
||||
function getOwnerKey(item) {
|
||||
const owner = item.Owner;
|
||||
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 !== "") {
|
||||
return `party:${partyId}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function createProductActivities({ productsContentState, opportunitiesQueryState, resetCrm,
|
||||
loadCrm, loadActions, render }) {
|
||||
function createProductActivities({ productsContentState, opportunitiesQueryState,
|
||||
loadCrm, loadActions, blockActions, render }) {
|
||||
let cycle = null;
|
||||
function begin(requestId, token) {
|
||||
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
|
||||
@@ -11,7 +11,16 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
||||
current.completed = true;
|
||||
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
|
||||
if (!products.length) return;
|
||||
resetCrm();
|
||||
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
|
||||
&& opportunitiesQueryState.periodRequestVersion === current.version;
|
||||
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
|
||||
if (!isActive()) return;
|
||||
if (success === false) {
|
||||
products.forEach(blockActions);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const pending = [];
|
||||
const seen = new Set();
|
||||
for (const result of products) {
|
||||
@@ -23,14 +32,11 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
||||
});
|
||||
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
|
||||
}
|
||||
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) }));
|
||||
return Promise.allSettled(pending).then(() => {
|
||||
if (cycle !== current || productsContentState.currentProductsRequestId !== requestId
|
||||
|| opportunitiesQueryState.periodRequestVersion !== current.version) return;
|
||||
await Promise.allSettled(pending);
|
||||
if (!isActive()) return;
|
||||
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
|
||||
render();
|
||||
}).catch(() => {});
|
||||
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
|
||||
}
|
||||
return { begin, complete };
|
||||
}
|
||||
|
||||
@@ -9,14 +9,7 @@
|
||||
.opportunities-extension-auth-badge { transition: none; }
|
||||
}
|
||||
.opportunities-extension-crm-sr-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #408254;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: #185b30;
|
||||
background: #e1f1e5;
|
||||
}
|
||||
@@ -91,14 +84,71 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 18px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
line-height: 18px;
|
||||
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) {
|
||||
.opportunities-extension-form {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -139,17 +189,10 @@
|
||||
column-gap: 8px;
|
||||
}
|
||||
.opportunities-extension-activity-filter-button {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 18px;
|
||||
min-height: 28px;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
min-width: 32px;
|
||||
gap: 4px;
|
||||
color: #185b30;
|
||||
background: transparent;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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-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: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="under4"] { color: #b5e9fc; }
|
||||
.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-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. */
|
||||
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
|
||||
width: 100%;
|
||||
@@ -281,18 +424,11 @@
|
||||
}
|
||||
|
||||
.opportunities-extension-products-ramp {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #bba5cf;
|
||||
border-radius: 8px;
|
||||
background: #f0e8f7;
|
||||
color: #63358a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ function createOpportunitiesTable({
|
||||
|
||||
const tableBody = document.createElement("tbody");
|
||||
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)) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
111
src/features/opportunities/workload-action-form.js
Normal file
111
src/features/opportunities/workload-action-form.js
Normal 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 };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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();
|
||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
|
||||
function updateProgress(current) {
|
||||
@@ -10,6 +10,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
||||
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
|
||||
}
|
||||
function reset(enabled = true) {
|
||||
closeActionForm(false);
|
||||
session.enabled = false;
|
||||
for (const job of session.queue) job.record.resolveCompletion();
|
||||
session.queue.length = 0;
|
||||
@@ -46,6 +47,12 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
||||
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
||||
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) {
|
||||
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
|
||||
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; }
|
||||
current.progress.add(record);
|
||||
updateProgress(current);
|
||||
let result;
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
try {
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
||||
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.hasNext = result.hasNext;
|
||||
record.status = "ready";
|
||||
@@ -107,7 +123,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
||||
const badge = environment.document.createElement("span");
|
||||
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
||||
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 => [
|
||||
`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"}`,
|
||||
@@ -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.";
|
||||
badge.tabIndex = 0;
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
|
||||
return badge;
|
||||
}
|
||||
return { reset, reopen, loadProducts, createBadge, getSummary };
|
||||
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createProductsDetails({
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
setBadgeContent,
|
||||
createAiSummaryAlert,
|
||||
formatOracleResponseDate,
|
||||
productsContentState,
|
||||
@@ -17,7 +18,7 @@ function createProductsDetails({
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-products-ramp";
|
||||
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.setAttribute("aria-label", badge.title);
|
||||
return badge;
|
||||
@@ -31,7 +32,7 @@ function createProductsDetails({
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-products-count";
|
||||
button.textContent = String(count);
|
||||
setBadgeContent(button, "products", count);
|
||||
button.setAttribute("aria-expanded", String(isExpanded));
|
||||
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
||||
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
||||
|
||||
@@ -21,6 +21,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
let attempt = 1;
|
||||
while (isActive()) {
|
||||
const payload = createPayload(offset);
|
||||
const headers = {
|
||||
@@ -28,31 +29,33 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||
};
|
||||
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 {
|
||||
const response = await fetch(url, {
|
||||
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
||||
});
|
||||
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();
|
||||
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
|
||||
throw new Error("CRM service request query returned an invalid response.");
|
||||
}
|
||||
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);
|
||||
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
||||
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;
|
||||
attempt = 1;
|
||||
} catch (error) {
|
||||
logRequestFailure(entry, error.message || "CRM service request query failed.");
|
||||
throw error;
|
||||
if (attempt === 5) throw error;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -25,7 +25,10 @@ function createHttpBackgroundJson({
|
||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -14,7 +14,9 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
|
||||
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.currentUser.allowsCrmActivities = () => true;
|
||||
app.preferencesState.opptyProductsCacheHours = 24;
|
||||
@@ -59,7 +61,7 @@ for (const family of ["chrome", "firefox"]) {
|
||||
const count = h.calls.length;
|
||||
await h.load(currentStamp, true);
|
||||
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.activityFilters.toggle("sr", "under4");
|
||||
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);
|
||||
await restored.load(stamp, true);
|
||||
assert.deepEqual(restored.calls, []);
|
||||
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (4h)");
|
||||
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "\u2705 Act");
|
||||
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
|
||||
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "1");
|
||||
await restored.load(stamp, false);
|
||||
assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications");
|
||||
const other = setup(family, h.saved, "other@example.invalid");
|
||||
await other.load(stamp, true);
|
||||
assert.deepEqual(other.calls, []);
|
||||
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);
|
||||
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.set = async () => { throw new Error("Synthetic storage write failure"); };
|
||||
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");
|
||||
failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); };
|
||||
failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false });
|
||||
|
||||
@@ -11,7 +11,7 @@ class Element {
|
||||
addEventListener(key, handler) { this.listeners[key] = handler; }
|
||||
}
|
||||
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;
|
||||
let renders = 0;
|
||||
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[3], h.stage);
|
||||
assert.equal(h.form.attributes["data-activity-filters"], "true");
|
||||
assert.equal(h.button("sr", "matched").textContent, "\u2705 SR");
|
||||
assert.equal(h.button("actions", "missing").textContent, "\u26d4 Action");
|
||||
assert.equal(h.button("sr", "matched").textContent, "");
|
||||
assert.equal(h.button("actions", "missing").textContent, "");
|
||||
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.actions.set("A", { 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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const h = setup();
|
||||
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 ]]) {
|
||||
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 = [];
|
||||
let finish;
|
||||
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 () => {
|
||||
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.productsContentJobs.queueProductsTableRender = () => {};
|
||||
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;/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
|
||||
|
||||
@@ -56,7 +56,9 @@ test("CRM reports real page counts and Actions reports queued and completed requ
|
||||
pending[0]({ ok: true, actions: [], hasNext: false });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]);
|
||||
pending[1]({ ok: false, error: "Synthetic failure" });
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
pending[attempt]({ ok: false, error: "Synthetic failure" });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
|
||||
});
|
||||
|
||||
108
tests/activityRetries.test.cjs
Normal file
108
tests/activityRetries.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ function setup(mockSalesToken = true) {
|
||||
const context = {
|
||||
document: {
|
||||
createElement: (tag) => new Element(tag),
|
||||
createElementNS: (_, tag) => new Element(tag),
|
||||
querySelectorAll: () => visibleAlerts
|
||||
},
|
||||
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
||||
|
||||
68
tests/allOpportunityFilters.test.cjs
Normal file
68
tests/allOpportunityFilters.test.cjs
Normal 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
113
tests/badgeIcons.test.cjs
Normal 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;/);
|
||||
});
|
||||
73
tests/consumerTokenAuthorizationRecovery.test.cjs
Normal file
73
tests/consumerTokenAuthorizationRecovery.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -3,7 +3,8 @@ const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
||||
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 });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
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"]]) {
|
||||
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);
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, `\u2705 SR (${expected})`);
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
|
||||
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");
|
||||
}
|
||||
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");
|
||||
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.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
|
||||
app.opportunityCrmServiceRequests.reset();
|
||||
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);
|
||||
assert.equal(loaded, true);
|
||||
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");
|
||||
}
|
||||
});
|
||||
@@ -133,8 +138,8 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
||||
await app.opportunityCrmServiceRequests.load("token", 2);
|
||||
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
|
||||
await old;
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR (0h)");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
||||
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;
|
||||
await app.opportunityCrmServiceRequests.load("token", 3);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR (0h)");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR (0h)");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "0h");
|
||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "0h");
|
||||
app.opportunityCrmServiceRequests.reset();
|
||||
await app.opportunityCrmServiceRequests.load("token", 3);
|
||||
assert.equal(calls, 3);
|
||||
|
||||
48
tests/dashboardTotals.test.cjs
Normal file
48
tests/dashboardTotals.test.cjs
Normal 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");
|
||||
});
|
||||
@@ -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.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].title, "1 product");
|
||||
assert.equal(customer.children[0].textContent, "Synthetic customer");
|
||||
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
|
||||
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");
|
||||
assert.equal(badge(), undefined);
|
||||
for (const [values, expected] of [
|
||||
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"],
|
||||
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 0,3,N/D,6,3"]
|
||||
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
|
||||
[[0], "0"], [[0, "3", null, " 6 ", 3], "0,3,N/D,6,3"]
|
||||
]) {
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
||||
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 {
|
||||
assert.equal(badge().textContent, expected);
|
||||
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(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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const h = setup();
|
||||
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);
|
||||
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.opportunitiesTable.renderOpportunitiesTable();
|
||||
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();
|
||||
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
||||
});
|
||||
|
||||
@@ -48,7 +48,8 @@ test("Last update ignores superseded cycles and settles failures without changin
|
||||
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||
current.reject(new Error("Synthetic failure"));
|
||||
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 () => {
|
||||
@@ -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" });
|
||||
await flush();
|
||||
assert.deepEqual(h.calls, ["reset SR", "123", "SR token"]);
|
||||
assert.deepEqual(h.calls, ["SR token", "123"]);
|
||||
const second = h.start("new-token");
|
||||
h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] });
|
||||
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, { state: "complete" });
|
||||
await flush();
|
||||
assert.deepEqual(h.calls, ["reset SR"]);
|
||||
assert.deepEqual(h.calls, []);
|
||||
const mixed = h.start();
|
||||
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
||||
h.progress(mixed, { state: "complete" });
|
||||
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 () => {
|
||||
|
||||
115
tests/workloadActionForm.test.cjs
Normal file
115
tests/workloadActionForm.test.cjs
Normal 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", "-"]);
|
||||
});
|
||||
@@ -52,7 +52,9 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO
|
||||
});
|
||||
|
||||
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.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
||||
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; });
|
||||
await flush();
|
||||
assert.equal(pending.length, 4);
|
||||
pending.slice(0, 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
pending.slice(attempt * 4, attempt * 4 + 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
|
||||
await flush();
|
||||
assert.equal(pending.length, 5);
|
||||
}
|
||||
assert.equal(pending.length, 21);
|
||||
assert.equal(complete, false);
|
||||
assert.equal(sharedComplete, false);
|
||||
pending[4]({ ok: true, actions: [], hasNext: false });
|
||||
pending[20]({ ok: true, actions: [], hasNext: false });
|
||||
await flush();
|
||||
assert.equal(complete, false, "cache write still pending");
|
||||
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" }] };
|
||||
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||
app.opportunityWorkloadActions.loadProducts(products);
|
||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ...");
|
||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "...");
|
||||
await flush();
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].authorization.value, "synthetic-wwb");
|
||||
const badge = app.opportunityWorkloadActions.createBadge("A");
|
||||
assert.equal(badge.textContent, "\u2705 Act");
|
||||
assert.equal(badge.textContent, "1");
|
||||
assert.match(badge.title, /<script>safe<\/script>/);
|
||||
app.opportunityWorkloadActions.loadProducts(products);
|
||||
await flush();
|
||||
assert.equal(calls.length, 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.loadProducts(products);
|
||||
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.opportunityWorkloadActions.loadProducts(products);
|
||||
await flush();
|
||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
|
||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
|
||||
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);
|
||||
await flush();
|
||||
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, /My action/);
|
||||
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, /My action/);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user